package vaultik_test import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage/faultstore" "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) // metadataObjectKey returns the key of the named file in the metadata // directory of the snapshot with the given human ID. func metadataObjectKey(snapshotID, name string) string { return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + name } // TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod checks // that prune deletes a directory under metadata/ holding only // db.zst.age, as a backup leaves when its manifest upload fails, once it // is older than the grace period, and keeps one newer than that. A // directory with a manifest is never deleted, however old. func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod( t *testing.T, ) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() storeDir := t.TempDir() store, err := storage.NewFileStorer(storeDir) require.NoError(t, err) const ( oldID = "testhost_home_2026-01-01T00:00:00Z" newID = "testhost_home_2026-01-02T00:00:00Z" completeID = "testhost_home_2026-01-03T00:00:00Z" ) oldDB := metadataObjectKey(oldID, "db.zst.age") newDB := metadataObjectKey(newID, "db.zst.age") completeDB := metadataObjectKey(completeID, "db.zst.age") completeManifest := metadataObjectKey(completeID, "manifest.json.zst") manifest, err := snapshot.EncodeManifest(&snapshot.Manifest{ SnapshotID: snapshot.RemoteSnapshotKey(completeID), }, 3) require.NoError(t, err) for _, key := range []string{oldDB, newDB, completeDB} { require.NoError(t, store.Put(ctx, key, strings.NewReader("db"))) } require.NoError(t, store.Put(ctx, completeManifest, bytes.NewReader(manifest))) gracePeriod := time.Hour beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute) for _, key := range []string{oldDB, completeDB, completeManifest} { require.NoError(t, os.Chtimes(filepath.Join(storeDir, key), beforeGracePeriod, beforeGracePeriod)) } stdout := &bytes.Buffer{} v := &vaultik.Vaultik{ Config: &config.Config{PruneGracePeriod: gracePeriod}, Storage: store, Stdout: stdout, UI: ui.NewWithColor(stdout, false), } v.SetContext(ctx) require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true})) _, err = store.Stat(ctx, oldDB) require.ErrorIs(t, err, storage.ErrNotFound, "a directory with no manifest older than the grace period "+ "must be deleted") for _, key := range []string{newDB, completeDB, completeManifest} { _, err = store.Stat(ctx, key) require.NoError(t, err, "%s must be kept", key) } var result vaultik.PruneBlobsResult require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) assert.Equal(t, 1, result.MetadataDirsDeleted) } // TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that // prune --force keeps a directory under metadata/ with no manifest when a // local snapshot record claims it, when the listing gives no time for an // object in it, or when its name is not a remote key. A fourth such // directory is named, deleted and reported, although there are no // unreferenced blobs to delete. func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() env := newListEnv(t) const ( claimedID = "testhost_home_2026-01-01T00:00:00Z" leftID = "testhost_home_2026-01-03T00:00:00Z" ) env.addLocal(t, claimedID, time.Now().Add(-time.Hour)) claimedDB := metadataObjectKey(claimedID, "db.zst.age") unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z", "db.zst.age") leftDB := metadataObjectKey(leftID, "db.zst.age") notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age" for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} { require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db"))) } // Written into the mock store's map directly rather than through // Put, so its listing gives no time. env.store.data[unknownAgeDB] = []byte("db") require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{Force: true})) assert.True(t, env.store.hasKey(claimedDB), "a directory a local snapshot record claims must be kept") assert.True(t, env.store.hasKey(unknownAgeDB), "a directory holding an object of unknown age must be kept") assert.True(t, env.store.hasKey(notRemoteKeyDB), "a directory whose name is not a remote key must be kept") assert.False(t, env.store.hasKey(leftDB), "a directory with no manifest, unclaimed and old, must be deleted") output := env.stdout.String() assert.Contains(t, output, "Found 1 metadata director(ies) left by "+ "backups that stopped before their manifest:") assert.Contains(t, output, "metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/") assert.NotContains(t, output, "not-a-remote-key") assert.Contains(t, output, "Deleted 1 metadata director(ies) with no manifest.") assert.NotContains(t, output, "No unreferenced blobs to remove.") } // TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt // names a directory under metadata/ with no manifest that prune is about // to delete, and that answering no keeps it. func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() env := newListEnv(t) const leftID = "testhost_home_2026-01-01T00:00:00Z" leftDB := metadataObjectKey(leftID, "db.zst.age") require.NoError(t, env.store.Put(context.Background(), leftDB, strings.NewReader("db"))) env.v.Stdin = strings.NewReader("n\n") require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}), vaultik.ErrNotConfirmed) assert.True(t, env.store.hasKey(leftDB), "a declined prune must keep the directory") output := env.stdout.String() assert.Contains(t, output, " metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n") assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ") assert.Contains(t, output, "Cancelled") assert.NotContains(t, output, "Deleted") } // TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json // counts a directory under metadata/ with no manifest that it cannot // delete, and then returns an error, so the command exits non-zero. func TestPruneFailedMetadataDirDeleteReturnsError(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() env := newListEnv(t) leftDB := metadataObjectKey("testhost_home_2026-01-01T00:00:00Z", "db.zst.age") require.NoError(t, env.store.Put(context.Background(), leftDB, strings.NewReader("db"))) store := faultstore.New(env.store) store.OnDelete = func(key string) bool { return strings.HasPrefix(key, "metadata/") } env.v.Storage = store err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true}) require.ErrorContains(t, err, "1 metadata director(ies) with no manifest could not be deleted") assert.True(t, env.store.hasKey(leftDB)) var result vaultik.PruneBlobsResult require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result), "stdout must hold the JSON document, got:\n%s", env.stdout.String()) assert.Equal(t, 1, result.MetadataDirsFailed) assert.Equal(t, 0, result.MetadataDirsDeleted) }