Files
vaultik/internal/vaultik/purge_local_remote_consistency_test.go
T
sneak ca91e218b4
check / check (push) Waiting to run
Refuse a backup destination an unplugged volume left empty (closes #239)
Every backup now writes vaultik-destination.txt at the destination
root. While the local index holds snapshots, a destination without it
is not present: snapshot create refuses before writing, prune and
snapshot purge fail, and snapshot list and snapshot remove warn.
remote nuke deletes the marker last. The file:// backend no longer
creates its destination directory and checks for it on every write;
that protects a directory on a removable volume, not the mount point
itself.

Tests back up into existing directories; fixtures standing for a
written destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, snapshot name selection left CreateSnapshot and the --force/--json test moved into confirmRemoveSnapshot, both unchanged.

Model: opus-5-5
2026-10-13 18:06:51 +00:00

150 lines
4.8 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/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)
}
v := &vaultik.Vaultik{
Storage: mockStorage,
Repositories: repos,
DB: db,
Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{},
}
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")
}