package vaultik_test import ( "bytes" "context" "io" "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/cli" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) // TestRestoreOnAnotherMachine proves the disaster-recovery path: a host // that has only the vaultik binary, the age secret key, and the storage // credentials — no local index, a different hostname, and no // age_recipients configured — can list, restore, and verify a snapshot // straight from the destination store. // // The backup half writes a snapshot with one index and hostname. The // restore half throws that index away entirely: a fresh, empty index and // a config written by `config init` and `config set storage_url`, as in // the README's steps for restoring on another machine, which shares // nothing with the original but the storage location and the secret key. // If restore or verify needed the original local index — or // the human snapshot ID that only that index holds — this test could not // run, because the recovery host can know neither. func TestRestoreOnAnotherMachine(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") restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(64 * 1024) maxBlobSize := int64(512 * 1024) sourceFiles := writeRecoverySourceTree(t, fs, dataDir, chunkSize) ctx := context.Background() // Backup host: one index, hostname test-host, age_recipients set. // runFileStorageBackup closes the index before returning, so nothing // below can lean on it. _, storer, originalID := runFileStorageBackup( ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize) // Recovery host: a fresh empty index, a different hostname, and no // age_recipients — only the secret key and the same storage location. configPath := filepath.Join(tempDir, "config.yml") runVaultikCommand(t, "--config", configPath, "config", "init") runVaultikCommand(t, "--config", configPath, "config", "set", "storage_url", "file://"+storeDir) recovery, stdout := newRecoveryHost(ctx, t, fs, storer, configPath) // The recovery index really is empty. This is the assertion that makes // the test a guard against restore quietly depending on the original // index: if it did, an empty index would make restore fail. localSnaps, err := recovery.Repositories.Snapshots.ListRecent(ctx, 100) require.NoError(t, err) require.Empty(t, localSnaps, "recovery host must start with no local index") // List: the snapshot shows up as remote-only, identified by its remote // key, with no recoverable human ID. require.NoError(t, recovery.ListSnapshots(true)) rows := decodeListJSON(t, stdout.String()) require.Len(t, rows, 1) remote := rows[0] assert.False(t, remote.LocallyTracked, "snapshot must be remote-only here") assert.Empty(t, remote.ID, "the human ID is unknown to the recovery host") require.Len(t, remote.RemoteKey, 64) assert.Equal(t, snapshot.RemoteSnapshotKey(originalID), remote.RemoteKey, "the listed key is the hashed snapshot ID") // Restore driven by the abbreviated identifier the table prints (the // first 12 hex of the remote key), then deep-verify from the store // keyed by the full remote key. Both are what a recovery host can know. require.NoError(t, recovery.Restore(&vaultik.RestoreOptions{ SnapshotID: remote.RemoteKey[:12], TargetDir: restoreDir, Verify: true, })) require.NoError(t, recovery.RunDeepVerify( remote.RemoteKey, &vaultik.VerifyOptions{Deep: true})) assertRestoredTreeMatches(t, fs, restoreDir, sourceFiles) // With no public key configured, a backup must refuse to start. err = recovery.CreateSnapshot(&vaultik.SnapshotCreateOptions{Cron: true}) require.ErrorContains(t, err, "age_recipients") } // writeRecoverySourceTree writes a small source tree spanning several // chunks (so restore reassembles real multi-chunk files) and returns the // content keyed by absolute path. func writeRecoverySourceTree( t *testing.T, fs afero.Fs, dataDir string, chunkSize int64, ) map[string][]byte { t.Helper() sourceFiles := map[string][]byte{ filepath.Join(dataDir, "notes.txt"): []byte("recover me"), filepath.Join(dataDir, "sub", "big.bin"): bytesPattern("big-", int(chunkSize*3)), filepath.Join(dataDir, "sub", "small.bin"): bytesPattern("small-", 128), } for path, content := range sourceFiles { require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, afero.WriteFile(fs, path, content, 0o644)) } return sourceFiles } // newRecoveryHost builds the Vaultik a replacement machine would run: an // empty in-memory index, a hostname different from the backup host, and // only the secret key plus the shared storer. Its config is read from // configPath by config.Load, as every command reads it. It returns the // instance and the buffer its stdout is wired to. func newRecoveryHost( ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer, configPath string, ) (*vaultik.Vaultik, *bytes.Buffer) { t.Helper() cfg, err := config.Load(configPath) require.NoError(t, err) // Set directly rather than through VAULTIK_AGE_SECRET_KEY, which a // parallel test cannot change. cfg.AgeSecretKey = testAgeSecretKey cfg.Hostname = "recovery-host" recoveryDB, err := database.New(ctx, ":memory:") require.NoError(t, err) t.Cleanup(func() { _ = recoveryDB.Close() }) stdout := &bytes.Buffer{} recovery := &vaultik.Vaultik{ Config: cfg, Storage: storer, Fs: fs, Repositories: database.NewRepositories(recoveryDB), DB: recoveryDB, Stdout: stdout, Stderr: io.Discard, UI: ui.NewWithColor(io.Discard, false), } recovery.SetContext(ctx) return recovery, stdout } // runVaultikCommand runs one vaultik command line in-process and fails the // test if it returns an error. The command writes the cli package's global // flag variables, so it must not be called from two tests that run at the // same time. func runVaultikCommand(t *testing.T, args ...string) { t.Helper() cmd := cli.NewRootCommand() cmd.SetArgs(args) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) require.NoError(t, cmd.Execute()) } // assertRestoredTreeMatches byte-compares every restored file against its // source content. func assertRestoredTreeMatches( t *testing.T, fs afero.Fs, restoreDir string, sourceFiles map[string][]byte, ) { t.Helper() for origPath, expected := range sourceFiles { restored := filepath.Join(restoreDir, origPath) got, err := afero.ReadFile(fs, restored) require.NoErrorf(t, err, "restored file missing: %s", restored) require.Truef(t, bytes.Equal(got, expected), "byte mismatch for %s", origPath) } }