package vaultik_test import ( "bytes" "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/log" "sneak.berlin/go/vaultik/internal/vaultik" ) // These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a // file:// destination at a volume's mount point leaves an empty directory // behind when the volume is unplugged. While the local index holds // snapshots, a destination without vaultik-destination.txt is not present, // so no command may write to it or conclude from it that the local // snapshots are gone. // markerFile is the name of the file a backup writes at the root of the // destination. const markerFile = "vaultik-destination.txt" // backUpThenUnplugMountPoint backs up to a file:// destination, then // moves the destination directory away and leaves an empty directory in // its place, as unplugging a volume mounted there would. It returns the // path of that empty directory. func backUpThenUnplugMountPoint( ctx context.Context, t *testing.T, ) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) { t.Helper() storeDir := filepath.Join(t.TempDir(), "mnt") v, repos, out := backUpToFileDestination(ctx, t, storeDir) require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged")) require.NoError(t, os.Mkdir(storeDir, 0o750)) return v, repos, out, storeDir } // assertUntouched checks that the local index still holds its one // snapshot and that nothing was written to the empty mount point. func assertUntouched( ctx context.Context, t *testing.T, repos *database.Repositories, storeDir string, ) { t.Helper() snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit) require.NoError(t, err) assert.Len(t, snapshots, 1, "no local snapshot record may be deleted") entries, err := os.ReadDir(storeDir) require.NoError(t, err) assert.Empty(t, entries, "nothing may be written to the empty mount point") } // TestFirstBackupWritesMarker checks that a backup writes the marker // file at the root of the destination. func TestFirstBackupWritesMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() storeDir := t.TempDir() backUpToFileDestination(context.Background(), t, storeDir) assert.FileExists(t, filepath.Join(storeDir, markerFile)) } // TestBackupRefusesDestinationWithoutMarker checks that snapshot create // --prune writes nothing and drops no local snapshot record when the // destination has no marker. func TestBackupRefusesDestinationWithoutMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t) err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Cron: true, Prune: true, }) require.ErrorContains(t, err, markerFile) assertUntouched(ctx, t, repos, storeDir) } // TestPruneRefusesDestinationWithoutMarker checks that prune fails and // deletes no local snapshot record when the destination has no marker. func TestPruneRefusesDestinationWithoutMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t) err := v.Prune(&vaultik.PruneOptions{Force: true}) require.ErrorContains(t, err, markerFile) assertUntouched(ctx, t, repos, storeDir) } // TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge // fails and deletes no local snapshot record when the destination has no // marker. func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t) err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ KeepLatest: true, Force: true, }) require.ErrorContains(t, err, markerFile) assertUntouched(ctx, t, repos, storeDir) } // TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot // remove warns that the metadata could not be removed from a destination // without a marker, instead of reporting that it was. func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t) result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"), &vaultik.RemoveOptions{Force: true}) require.NoError(t, err) assert.False(t, result.RemoteRemoved) assert.Contains(t, out.String(), "Could not remove snapshot metadata from remote") assert.Contains(t, out.String(), markerFile) assert.NotContains(t, out.String(), "Removed snapshot metadata from remote storage") } // TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot // list warns and shows the local index alone when the destination has no // marker, without reporting the local snapshot as missing from it. func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t) id := localSnapshotID(ctx, t, repos, "first") require.NoError(t, v.ListSnapshots(false)) assert.Contains(t, out.String(), "Could not list backup destination store") assert.Contains(t, out.String(), markerFile) assert.Contains(t, out.String(), id) assert.NotContains(t, out.String(), "not found in backup destination store") } // TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the // destination ready for a new first backup, which writes the marker // again. func TestBackupAfterNukeRemoteSucceeds(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() storeDir := t.TempDir() v, _, _ := backUpToFileDestination(context.Background(), t, storeDir) require.NoError(t, v.NukeRemote(true)) assert.NoFileExists(t, filepath.Join(storeDir, markerFile)) require.NoError(t, backUp(v, "second")) assert.FileExists(t, filepath.Join(storeDir, markerFile)) }