diff --git a/internal/cli/entry_snapshot_purge_test.go b/internal/cli/entry_snapshot_purge_test.go new file mode 100644 index 0000000..64d7a81 --- /dev/null +++ b/internal/cli/entry_snapshot_purge_test.go @@ -0,0 +1,68 @@ +package cli //nolint:testpackage // shares runEntry and the hermetic config helpers + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/database" + "sneak.berlin/go/vaultik/internal/snapshot" +) + +// Words and flags of `snapshot purge` used to build argument vectors +// below. +const ( + cmdPurge = "purge" + flagOlderThan = "--older-than" + flagForce = "--force" +) + +// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes +// the local index refuse to delete an old snapshot and checks that +// `snapshot purge` exits non-zero and leaves that snapshot's metadata on +// the destination store. It used to log the error, delete the metadata +// and exit 0. +// +// `snapshot create --prune` runs the same purge, but cannot be driven +// this way: the refusal also breaks the backup's metadata export, which +// deletes the other snapshots from its copy of the local index. +// +//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals +func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) { + configPath := writeHermeticPruneConfig(t, false) + dir := filepath.Dir(configPath) + indexPath := filepath.Join(dir, "index.sqlite") + + // seedStaleSnapshotRecord writes an old snapshot to the local index + // only; the manifest planted here puts it on the destination store + // too, so the purge selects it rather than dropping it as stale. + seedStaleSnapshotRecord(t, indexPath) + + manifestDir := filepath.Join(dir, "store", "metadata", + snapshot.RemoteSnapshotKey(stalePruneSnapshotID)) + require.NoError(t, os.MkdirAll(manifestDir, 0o750)) + + manifestPath := filepath.Join(manifestDir, "manifest.json.zst") + require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600)) + + ctx := context.Background() + + db, err := database.New(ctx, indexPath) + require.NoError(t, err) + + _, err = db.Conn().ExecContext(ctx, ` + CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots + BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`) + require.NoError(t, err) + require.NoError(t, db.Close()) + + code, _, _ := runEntry(t, flagConfig, configPath, + cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce) + + assert.Equal(t, 1, code) + assert.FileExists(t, manifestPath, + "the snapshot is still in the local index, so its metadata must stay") +} diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 630e2ba..cef9cbf 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -669,10 +669,11 @@ func (v *Vaultik) confirmAndExecutePurge( snapshotID := snap.ID.String() log.Info("Deleting snapshot", "id", snapshotID) + // Stop before the remote delete, so a snapshot still in the local + // index keeps its metadata on the destination store. err := v.deleteSnapshotFromLocalDB(snapshotID) if err != nil { - log.Error("Failed to delete from local database", - "snapshot_id", snapshotID, "error", err) + return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err) } _, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))