Files
vaultik/internal/vaultik/purge_local_remote_consistency_test.go
T
sneak 7af92c67ff
check / check (push) Waiting to run
Refuse a backup destination an unplugged volume left empty (closes #239)
Every backup writes vaultik-destination.txt at the destination root.
While the local index holds snapshots stored there, a destination
without it is not present: snapshot create refuses before writing,
prune and snapshot purge fail, and snapshot list and snapshot remove
act as for a destination that cannot be listed. snapshot create checks
before dropping incomplete snapshots, snapshot remove before looking on
the destination. remote nuke deletes the marker last. The file://
backend never creates its destination directory, which protects a
directory on a removable volume, not a mount point.

Tests back up into directories that exist; fixtures for a written
destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, two blocks moved, unchanged, into
snapshotNamesToCreate and confirmRemoveSnapshot.

Model: opus-5-5
2026-10-14 03:16:20 +00:00

154 lines
4.9 KiB
Go

package vaultik_test
import (
"bytes"
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// setupConsistencyTest builds a Vaultik whose local database and mock
// remote both hold the given snapshots. Remote metadata is stored under
// the production layout, metadata/<RemoteSnapshotKey(id)>/manifest.json.zst.
// It returns the instance and the mock so a test can inspect the remote.
func setupConsistencyTest(
t *testing.T, snapshotIDs []string,
) (*vaultik.Vaultik, *MockStorer) {
t.Helper()
ctx := context.Background()
db, err := database.New(ctx, ":memory:")
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
mockStorage := NewMockStorer()
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
for _, id := range snapshotIDs {
parts := strings.Split(id, "_")
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
require.NoError(t, err, "parsing timestamp from snapshot ID %q", id)
completedAt := startedAt.Add(5 * time.Minute)
snap := &database.Snapshot{
ID: types.SnapshotID(id),
Hostname: snapHostname,
VaultikVersion: testLabel,
StartedAt: startedAt,
CompletedAt: &completedAt,
}
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return repos.Snapshots.Create(ctx, tx, snap)
})
require.NoError(t, err, "creating snapshot %s", id)
metadataKey := "metadata/" + snapshot.RemoteSnapshotKey(id) +
"/manifest.json.zst"
err = mockStorage.Put(ctx, metadataKey, strings.NewReader("stub"))
require.NoError(t, err)
}
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Storage: mockStorage,
Repositories: repos,
DB: db,
Stdout: stdout,
Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
return v, mockStorage
}
func remoteHasSnapshot(t *testing.T, m *MockStorer, id string) bool {
t.Helper()
prefix := "metadata/" + snapshot.RemoteSnapshotKey(id) + "/"
keys, err := m.List(context.Background(), prefix)
require.NoError(t, err)
return len(keys) > 0
}
// TestPurgeKeepsRemotelyBackedLocalRows guards against issue #160
// (https://git.eeqj.de/sneak/vaultik/issues/160): purge reconciles local
// rows against the remote first, and that step compared human snapshot IDs
// against the hashed remote directory names, which never match — so it
// deleted every local record and the purge itself then removed nothing.
//
// With every snapshot still present remotely and nothing old enough to
// purge, all local rows must survive the reconcile untouched.
func TestPurgeKeepsRemotelyBackedLocalRows(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ids := []string{snapHomeT0, snapHomeT1, snapSystemT0}
v, _ := setupConsistencyTest(t, ids)
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
// 100 years: nothing is old enough to delete, so the reconcile
// is the only thing that touches the rows.
OlderThan: "36500d",
Force: true,
})
require.NoError(t, err)
remaining := listRemainingSnapshots(t, v)
assert.Len(t, remaining, len(ids),
"remotely-backed local rows must survive the reconcile")
assert.Contains(t, remaining, snapHomeT0)
assert.Contains(t, remaining, snapHomeT1)
assert.Contains(t, remaining, snapSystemT0)
}
// TestPurgeRemovesLocalAndRemoteTogether proves the two halves stay
// consistent: a purged snapshot is gone both locally and remotely, while a
// retained one keeps both. Before the fix, the reconcile dropped every
// local row yet the remote metadata was left in place.
func TestPurgeRemovesLocalAndRemoteTogether(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ids := []string{snapHomeT0, snapHomeT1, snapSystemT0}
v, mock := setupConsistencyTest(t, ids)
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
})
require.NoError(t, err)
// Keep latest per name: newest home and the lone system are kept.
remaining := listRemainingSnapshots(t, v)
assert.ElementsMatch(t, []string{snapHomeT1, snapSystemT0}, remaining)
// Local and remote agree: the older home snapshot is gone from both,
// the retained ones are present in both.
assert.False(t, remoteHasSnapshot(t, mock, snapHomeT0),
"purged snapshot must also be removed remotely")
assert.True(t, remoteHasSnapshot(t, mock, snapHomeT1),
"retained snapshot must remain remotely")
assert.True(t, remoteHasSnapshot(t, mock, snapSystemT0),
"retained snapshot must remain remotely")
}