package vaultik_test import ( "bytes" "context" "encoding/json" "io" "os" "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) // TestShallowVerifyDetectsWrongBlobSize backs up a real snapshot, runs // shallow verify (which passes and reports exactly the blobs it checked), // then grows one stored blob so its size no longer matches the manifest. // Shallow verify must then fail, count the grown blob as a size mismatch, // and drop it from the verified count rather than continuing to report it // as checked. func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") storeDir := filepath.Join(tempDir, "remote") dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) maxBlobSize := int64(128 * 1024) // Enough data to span several blobs, so the mismatch count and the // dropped verified count are both meaningful. require.NoError(t, fs.MkdirAll(dataDir, 0o755)) require.NoError(t, afero.WriteFile(fs, filepath.Join(dataDir, "data.bin"), bytesPattern("shallow-", int(maxBlobSize*4)), 0o644)) ctx := context.Background() cfg, storer, snapshotID := runFileStorageBackup( ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize) newVerifier := func(out io.Writer) *vaultik.Vaultik { v := &vaultik.Vaultik{ Config: cfg, Storage: storer, Fs: fs, Stdout: out, Stderr: io.Discard, UI: ui.NewWithColor(io.Discard, false), } v.SetContext(ctx) return v } var out bytes.Buffer require.NoError(t, newVerifier(&out).VerifySnapshotWithOptions( snapshotID, &vaultik.VerifyOptions{JSON: true}), "shallow verify should pass on a healthy snapshot") healthy := decodeVerifyResult(t, out.Bytes()) require.Equal(t, "ok", healthy.Status) require.Positive(t, healthy.BlobCount) require.Equal(t, healthy.BlobCount, healthy.Verified, "shallow verify must report exactly the blobs it checked") require.Zero(t, healthy.Mismatched) // Grow one stored blob so its size no longer matches the manifest. growOneBlob(t, fs, filepath.Join(storeDir, "blobs")) out.Reset() err := newVerifier(&out).VerifySnapshotWithOptions( snapshotID, &vaultik.VerifyOptions{JSON: true}) require.Error(t, err, "shallow verify must fail when a blob's stored size differs from the manifest") bad := decodeVerifyResult(t, out.Bytes()) require.Equal(t, "failed", bad.Status) require.Equal(t, 1, bad.Mismatched) require.Equal(t, healthy.BlobCount-1, bad.Verified, "the wrong-sized blob must not be counted as verified") } // TestShallowVerifyDetectsMissingDatabase backs up a real snapshot, // confirms shallow verify passes, then deletes the snapshot's encrypted // database. Shallow verify must fail: a snapshot without its database is // not restorable, even when every blob is present. func TestShallowVerifyDetectsMissingDatabase(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") storeDir := filepath.Join(tempDir, "remote") dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) maxBlobSize := int64(128 * 1024) require.NoError(t, fs.MkdirAll(dataDir, 0o755)) require.NoError(t, afero.WriteFile(fs, filepath.Join(dataDir, "data.bin"), bytesPattern("shallow-db-", int(maxBlobSize*2)), 0o644)) ctx := context.Background() cfg, storer, snapshotID := runFileStorageBackup( ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize) newVerifier := func() *vaultik.Vaultik { v := &vaultik.Vaultik{ Config: cfg, Storage: storer, Fs: fs, Stdout: io.Discard, Stderr: io.Discard, UI: ui.NewWithColor(io.Discard, false), } v.SetContext(ctx) return v } require.NoError(t, newVerifier().VerifySnapshotWithOptions( snapshotID, &vaultik.VerifyOptions{}), "shallow verify should pass on a healthy snapshot") // The database lives under the hashed remote key, not the human ID. dbObject := filepath.Join(storeDir, "metadata", snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age") require.NoError(t, os.Remove(dbObject)) require.Error(t, newVerifier().VerifySnapshotWithOptions( snapshotID, &vaultik.VerifyOptions{}), "shallow verify must fail when db.zst.age is absent") } // growOneBlob appends bytes to the first blob file found under blobsDir, // changing its on-disk size so it no longer matches the manifest. func growOneBlob(t *testing.T, fs afero.Fs, blobsDir string) { t.Helper() var blobPath string err := afero.Walk(fs, blobsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if blobPath == "" && !info.IsDir() { blobPath = path } return nil }) require.NoError(t, err) require.NotEmpty(t, blobPath, "expected at least one blob on disk") data, err := afero.ReadFile(fs, blobPath) require.NoError(t, err) data = append(data, []byte("extra")...) require.NoError(t, afero.WriteFile(fs, blobPath, data, 0o644)) } func decodeVerifyResult(t *testing.T, b []byte) vaultik.VerifyResult { t.Helper() var result vaultik.VerifyResult require.NoError(t, json.Unmarshal(b, &result)) return result }