package cli //nolint:testpackage // shares runEntry and the hermetic config helpers import ( "context" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/database" ) // Flags of `snapshot create` used to build argument vectors below. const ( flagPrune = "--prune" flagKeepNewerThan = "--keep-newer-than" ) // TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup checks that a // --keep-newer-than the post-backup prune could not use stops `snapshot // create` before the backup: the command fails and the destination // store stays empty. It used to be read only by the prune, after the // snapshot was uploaded, and without --prune it was ignored. // //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) { for _, testCase := range []struct { name string args []string }{ { name: "decimal in a day count", args: []string{flagPrune, flagKeepNewerThan, "1.5d"}, }, { name: "unknown unit", args: []string{flagPrune, flagKeepNewerThan, "4x"}, }, { name: "without --prune", args: []string{flagKeepNewerThan, "4w"}, }, } { t.Run(testCase.name, func(t *testing.T) { configPath, storeDir := writeBackupConfig(t) code, _, _ := runEntry(t, append( []string{flagConfig, configPath, cmdSnapshot, cmdCreate}, testCase.args...)...) assert.Equal(t, 1, code) stored, err := os.ReadDir(storeDir) require.NoError(t, err) assert.Empty(t, stored, "nothing may be uploaded") }) } } // TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the // post-backup prune fail after a good backup, by planting a manifest // that cannot be decoded, and checks that the failure is reported as the // prune's and not as a failed snapshot creation. // //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) { configPath, storeDir := writeBackupConfig(t) // A remote key is 64 lowercase hex characters. The prune reads every // manifest on the store and fails on one it cannot decode. plantedDir := filepath.Join(storeDir, "metadata", strings.Repeat("a", 64)) require.NoError(t, os.MkdirAll(plantedDir, 0o750)) require.NoError(t, os.WriteFile( filepath.Join(plantedDir, "manifest.json.zst"), []byte("not a valid manifest"), 0o600)) code, _, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune) assert.Equal(t, 1, code) assert.Contains(t, stderr, "Backup completed, but post-backup prune failed") assert.NotContains(t, stderr, "Snapshot creation failed") snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata")) require.NoError(t, err) assert.Len(t, snapshots, 2, "the new snapshot is on the store beside the planted one") } // TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up, // empties the source directory, as a volume that is not mounted leaves // its mount point, and runs `snapshot create --prune`. The command exits // non-zero and reports the backup as completed, not as a failed snapshot // creation. See https://git.eeqj.de/sneak/vaultik/issues/388. // //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) { configPath, storeDir := writeBackupConfig(t) code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate) require.Equal(t, 0, code) // writeBackupConfig puts the source directory beside the config file. require.NoError(t, os.Remove( filepath.Join(filepath.Dir(configPath), "source", "file.txt"))) code, _, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune) assert.Equal(t, 1, code) assert.Contains(t, stderr, "Backup completed, but 1 source path(s) were empty") assert.NotContains(t, stderr, "Snapshot creation failed") snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata")) require.NoError(t, err) assert.Len(t, snapshots, 2, "the older snapshot is kept") } // TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs // up, records in the local index that this backup found the source // directory as a mount point, and runs `snapshot create --prune`, which // does not find it as one. The command exits non-zero, reports the // backup as completed, not as a failed snapshot creation, and keeps the // older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395. // //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing( t *testing.T, ) { configPath, storeDir := writeBackupConfig(t) code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate) require.Equal(t, 0, code) // writeBackupConfig puts the source directory and the local index // beside the config file. dir := filepath.Dir(configPath) ctx := context.Background() db, err := database.New(ctx, filepath.Join(dir, "index.sqlite")) require.NoError(t, err) repos := database.NewRepositories(db) backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx) require.NoError(t, err) require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil, backedUp.ID.String(), filepath.Join(dir, "source"))) require.NoError(t, db.Close()) code, _, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune) assert.Equal(t, 1, code) assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+ "an earlier snapshot recorded were not found") assert.NotContains(t, stderr, "Snapshot creation failed") snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata")) require.NoError(t, err) assert.Len(t, snapshots, 2, "the older snapshot is kept") } // writeBackupConfig builds a hermetic config with one small file in its // source directory and an empty destination store directory. Returns the // config path and the store directory. func writeBackupConfig(t *testing.T) (string, string) { t.Helper() configPath := writeHermeticPruneConfig(t, false) // writeHermeticPruneConfig puts the source and store directories // beside the config file. dir := filepath.Dir(configPath) sourceDir := filepath.Join(dir, "source") require.NoError(t, os.Mkdir(sourceDir, 0o750)) require.NoError(t, os.WriteFile( filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600)) return configPath, filepath.Join(dir, "store") }