Files
vaultik/internal/vaultik/purge_local_remote_consistency_test.go
T
clawbot 30285c7a8c
check / check (push) Waiting to run
Silence prune and snapshot removal status under --cron and --quiet (closes #294)
PruneBlobs, CleanupLocalSnapshots, RemoveSnapshot, RemoveAllSnapshots
and snapshot purge wrote their status lines straight to stdout, so
`snapshot create --cron --prune` printed on every successful run and
--quiet did not silence `prune`, `snapshot purge`, `snapshot remove` or
`remote nuke`. Those lines now go through internal/ui, which drops them
under --cron and --quiet; warnings, errors and --json documents still
print.

The confirmation prompts still write straight to stdout, as the
`database delete` prompt does, together with what they ask to delete:
the snapshot list before the purge prompt, and the blob count and size
before the prune prompt. With --force those lines are status lines.

The two purge test fixtures gained the UI writer production always sets.

Model: opus-5-5
2026-10-14 01:10:43 +02:00

151 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/ui"
"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()
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)
}
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Storage: mockStorage,
Repositories: repos,
DB: db,
Stdout: stdout,
Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false),
}
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")
}