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" ) // Snapshot IDs reused across the purge tests, and the hostname they were // taken on. const ( snapHostname = "testhost" snapSystemT0 = "testhost_system_2026-01-01T00:00:00Z" snapHomeT0 = "testhost_home_2026-01-01T00:00:00Z" snapHomeT1 = "testhost_home_2026-01-01T01:00:00Z" snapHomeT3 = "testhost_home_2026-01-01T03:00:00Z" ) // setupPurgeTest creates a Vaultik instance with an in-memory database and mock // storage pre-populated with the given snapshot IDs, all taken on hostname. // Each snapshot is marked as completed. Remote metadata stubs are created so // syncWithRemote keeps them. func setupPurgeTest( t *testing.T, hostname string, snapshotIDs []string, ) *vaultik.Vaultik { 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(""))) // Insert each snapshot into the DB and create remote metadata stubs. // Use timestamps parsed from snapshot IDs for realistic ordering. for _, id := range snapshotIDs { // Parse timestamp from the snapshot ID parts := strings.Split(id, "_") timestampStr := parts[len(parts)-1] startedAt, err := time.Parse(time.RFC3339, timestampStr) 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: types.Hostname(hostname), 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) // Create the remote metadata stub under the production layout so // syncWithRemote keeps the local row. Production stores metadata // under the hashed remote key, not the human snapshot ID. metadataKey := "metadata/" + snapshot.RemoteSnapshotKey(id) + "/manifest.json.zst" err = mockStorage.Put(ctx, metadataKey, strings.NewReader("stub")) require.NoError(t, err) } stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} stdin := &bytes.Buffer{} v := &vaultik.Vaultik{ Storage: mockStorage, Repositories: repos, DB: db, Stdout: stdout, Stderr: stderr, Stdin: stdin, UI: ui.NewWithColor(stdout, false), } v.SetContext(ctx) return v } // listRemainingSnapshots returns IDs of all completed snapshots in the database. func listRemainingSnapshots(t *testing.T, v *vaultik.Vaultik) []string { t.Helper() ctx := context.Background() dbSnaps, err := v.Repositories.Snapshots.ListRecent(ctx, 10000) require.NoError(t, err) var ids []string for _, s := range dbSnaps { if s.CompletedAt != nil { ids = append(ids, s.ID.String()) } } return ids } func TestPurgeKeepLatest_PerName(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() // Create snapshots for two different names: "home" and "system". // With per-name --keep-latest, the latest of each should be kept. snapshotIDs := []string{ snapSystemT0, snapHomeT1, "testhost_system_2026-01-01T02:00:00Z", snapHomeT3, "testhost_system_2026-01-01T04:00:00Z", } v := setupPurgeTest(t, snapHostname, snapshotIDs) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, }) require.NoError(t, err) remaining := listRemainingSnapshots(t, v) // Should keep the latest of each name assert.Len(t, remaining, 2, "should keep exactly 2 snapshots (one per name)") assert.Contains(t, remaining, "testhost_system_2026-01-01T04:00:00Z", "should keep latest system") assert.Contains(t, remaining, snapHomeT3, "should keep latest home") } func TestPurgeKeepLatest_SingleName(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() // All snapshots have the same name — keep-latest should keep exactly one. snapshotIDs := []string{ snapHomeT0, snapHomeT1, "testhost_home_2026-01-01T02:00:00Z", } v := setupPurgeTest(t, snapHostname, snapshotIDs) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, }) require.NoError(t, err) remaining := listRemainingSnapshots(t, v) assert.Len(t, remaining, 1) assert.Contains(t, remaining, "testhost_home_2026-01-01T02:00:00Z", "should keep the newest") } func TestPurgeKeepLatest_WithNameFilter(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() // Use --name to filter purge to only "home" snapshots. // "system" snapshots should be untouched. snapshotIDs := []string{ snapSystemT0, snapHomeT1, "testhost_system_2026-01-01T02:00:00Z", snapHomeT3, "testhost_home_2026-01-01T04:00:00Z", } v := setupPurgeTest(t, snapHostname, snapshotIDs) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, Names: []string{"home"}, }) require.NoError(t, err) remaining := listRemainingSnapshots(t, v) // 2 system snapshots untouched + 1 latest home = 3 assert.Len(t, remaining, 3) assert.Contains(t, remaining, snapSystemT0) assert.Contains(t, remaining, "testhost_system_2026-01-01T02:00:00Z") assert.Contains(t, remaining, "testhost_home_2026-01-01T04:00:00Z") } func TestPurgeKeepLatest_NoSnapshots(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() v := setupPurgeTest(t, snapHostname, nil) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, }) require.NoError(t, err) } func TestPurgeKeepLatest_NameFilterNoMatch(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() snapshotIDs := []string{ snapSystemT0, "testhost_system_2026-01-01T01:00:00Z", } v := setupPurgeTest(t, snapHostname, snapshotIDs) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, Names: []string{"nonexistent"}, }) require.NoError(t, err) // All snapshots should remain — the name filter matched nothing remaining := listRemainingSnapshots(t, v) assert.Len(t, remaining, 2) } func TestPurgeOlderThan_WithNameFilter(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() // Snapshots with different names and timestamps. // --older-than should apply only to the named subset when --name is used. snapshotIDs := []string{ "testhost_system_2020-01-01T00:00:00Z", "testhost_home_2020-01-01T00:00:00Z", snapSystemT0, snapHomeT0, } v := setupPurgeTest(t, snapHostname, snapshotIDs) // Purge only "home" snapshots older than 365 days err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ OlderThan: "365d", Force: true, Names: []string{"home"}, }) require.NoError(t, err) remaining := listRemainingSnapshots(t, v) // Old system stays (not filtered by name), old home deleted, recent ones stay assert.Len(t, remaining, 3) assert.Contains(t, remaining, "testhost_system_2020-01-01T00:00:00Z") assert.Contains(t, remaining, snapSystemT0) assert.Contains(t, remaining, snapHomeT0) } func TestPurgeOlderThan_TooLargeDeletesNothing(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() snapshotIDs := []string{snapHomeT0, snapHomeT1} v := setupPurgeTest(t, snapHostname, snapshotIDs) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ OlderThan: "1000y", Force: true, }) require.EqualError(t, err, `invalid duration: "1000y" is longer than about 292 years`) err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ OlderThan: "292y", Force: true, }) require.NoError(t, err) assert.ElementsMatch(t, snapshotIDs, listRemainingSnapshots(t, v)) } func TestPurgeKeepLatest_ThreeNames(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() // Three different snapshot names with multiple snapshots each. snapshotIDs := []string{ snapHomeT0, "testhost_system_2026-01-01T01:00:00Z", "testhost_media_2026-01-01T02:00:00Z", "testhost_home_2026-01-01T03:00:00Z", "testhost_system_2026-01-01T04:00:00Z", "testhost_media_2026-01-01T05:00:00Z", "testhost_home_2026-01-01T06:00:00Z", } v := setupPurgeTest(t, snapHostname, snapshotIDs) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, }) require.NoError(t, err) remaining := listRemainingSnapshots(t, v) assert.Len(t, remaining, 3, "should keep one per name") assert.Contains(t, remaining, "testhost_home_2026-01-01T06:00:00Z") assert.Contains(t, remaining, "testhost_system_2026-01-01T04:00:00Z") assert.Contains(t, remaining, "testhost_media_2026-01-01T05:00:00Z") } // A hostname may contain underscores, so the snapshot name cannot be found // by splitting the ID at them. A purge by name must still select "docs". func TestPurgeKeepLatest_HostnameWithUnderscore(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() const ( system = "my_host_system_2026-01-01T00:00:00Z" docsT1 = "my_host_docs_2026-01-01T01:00:00Z" docsT2 = "my_host_docs_2026-01-01T02:00:00Z" ) v := setupPurgeTest(t, "my_host", []string{system, docsT1, docsT2}) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, Names: []string{"docs"}, }) require.NoError(t, err) assert.ElementsMatch(t, []string{system, docsT2}, listRemainingSnapshots(t, v)) }