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//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() 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") }