package vaultik_test import ( "bytes" "context" "os" "path/filepath" "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/storage" "sneak.berlin/go/vaultik/internal/storage/faultstore" "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 that answers that it has no // 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) } // TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on // and writes the marker when looking it up fails with an error other // than not found, as S3 answers credentials that allow only PUT // (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker // that is not found still stops it. func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() storeDir := t.TempDir() v, repos, _ := backUpToFileDestination(ctx, t, storeDir) // Removed so the test sees the backup write it again: with the // look-up failing, the backup cannot tell that it is gone. markerPath := filepath.Join(storeDir, markerFile) require.NoError(t, os.Remove(markerPath)) store := faultstore.New(v.Storage) store.OnStat = func(key string) bool { return key == markerFile } v.Storage = store require.NoError(t, backUp(v, "second")) localSnapshotID(ctx, t, repos, "second") assert.FileExists(t, markerPath) require.NoError(t, os.Remove(markerPath)) store.OnStat = nil require.ErrorContains(t, backUp(v, "third"), markerFile) } // TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that // snapshot create writes nothing when the local index holds only an // incomplete snapshot and the destination has no marker. The backup drops // incomplete snapshots before it scans; checked after that, the local // index would hold none and nothing would be checked. func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() storeDir := t.TempDir() v, repos := newFileDestinationVaultik(ctx, t, storeDir) require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{ ID: "host_first_2026-01-02T03:04:05Z", Hostname: "host", StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), })) err := backUp(v, "first") 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) } // TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke // fails, deletes no local snapshot record, and neither reports the // destination empty nor advises running nuke again when the destination // has no marker. func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t) err := v.NukeRemote(true) require.ErrorContains(t, err, markerFile) assert.NotContains(t, out.String(), "now empty") assert.NotContains(t, out.String(), "vaultik remote nuke --force") 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") } // TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that // snapshot remove, for a snapshot that is only on the destination, fails // saying the destination is not present when it has no marker, instead // of saying the snapshot does not exist. func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() storeDir := filepath.Join(t.TempDir(), "mnt") v, repos, _ := backUpToFileDestination(ctx, t, storeDir) require.NoError(t, backUp(v, "second")) id := localSnapshotID(ctx, t, repos, "first") _, err := v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true, LocalOnly: true}) require.NoError(t, err) // Unplug the volume: an empty directory stays at its mount point. require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged")) require.NoError(t, os.Mkdir(storeDir, 0o750)) _, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true}) require.ErrorContains(t, err, markerFile) require.ErrorContains(t, err, "mount the volume") assertUntouched(ctx, t, repos, storeDir) } // 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") } // TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that // snapshot list, after storage_url is changed to an empty directory, // warns only that the local index is bound to a different destination: // the local snapshots are stored at the old one, so the new one's lack // of a marker says nothing. With --json the local row is not on the new // destination, so remote_present is false, not unknown. func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() v, repos, out := backUpToFileDestination(ctx, t, t.TempDir()) id := localSnapshotID(ctx, t, repos, "first") newDir := t.TempDir() store, err := storage.NewFileStorer(newDir) require.NoError(t, err) v.Storage = store v.Config.StorageURL = "file://" + newDir require.NoError(t, v.ListSnapshots(false)) assert.Contains(t, out.String(), id) assert.Contains(t, out.String(), "local index is bound to a different backup destination") assert.NotContains(t, out.String(), "Could not list backup destination store") assert.NotContains(t, out.String(), markerFile) out.Reset() require.NoError(t, v.ListSnapshots(true)) rows := decodeListJSON(t, out.String()) require.Len(t, rows, 1) require.NotNil(t, rows[0].RemotePresent) assert.False(t, *rows[0].RemotePresent) } // 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)) }