check / check (push) Canceled after 0s
Every backup writes vaultik-destination.txt at the destination root. While the local index holds snapshots stored there, a destination without it is not present: snapshot create refuses before writing, prune and snapshot purge fail, and snapshot list and snapshot remove act as for a destination that cannot be listed. snapshot create and snapshot remove check it first. remote nuke deletes the marker last. The file:// backend never creates its destination directory, which protects a directory on a removable volume, not a mount point. Tests back up into directories that exist; fixtures for a written destination carry the marker. Judgement call: the marker is rewritten on every backup. Deviation: for the complexity limit, two blocks moved, unchanged, into snapshotNamesToCreate and confirmRemoveSnapshot. Model: opus-5-5
191 lines
6.1 KiB
Go
191 lines
6.1 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"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/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/220: a
|
|
// file:// destination whose directory is missing, such as a USB stick
|
|
// that is not plugged in, cannot be listed. It is not an empty store, so
|
|
// no command may conclude from it that the local snapshots are gone.
|
|
|
|
// newFileDestinationVaultik returns a Vaultik that backs up the
|
|
// snapshot named "first" to a file:// destination at storeDir.
|
|
func newFileDestinationVaultik(
|
|
ctx context.Context, t *testing.T, storeDir string,
|
|
) (*vaultik.Vaultik, *database.Repositories) {
|
|
t.Helper()
|
|
|
|
osFs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
dataDir := filepath.Join(tempDir, "src")
|
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
|
|
writeFaultSourceTree(t, osFs, dataDir)
|
|
|
|
store, err := storage.NewFileStorer(storeDir)
|
|
require.NoError(t, err)
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
repos := database.NewRepositories(db)
|
|
cfg := changedFileConfig(dataDir, dbPath)
|
|
|
|
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
|
}
|
|
|
|
// backUpToFileDestination creates the directory storeDir, as a user must
|
|
// before the first backup, and backs up the snapshot named "first" to a
|
|
// file:// destination there. Everything the returned Vaultik prints
|
|
// after the backup goes to the returned buffer.
|
|
func backUpToFileDestination(
|
|
ctx context.Context, t *testing.T, storeDir string,
|
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
|
|
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
require.NoError(t, backUp(v, "first"))
|
|
|
|
out := &bytes.Buffer{}
|
|
v.Stdout = out
|
|
v.UI = ui.NewWithColor(out, false)
|
|
|
|
return v, repos, out
|
|
}
|
|
|
|
// backUpThenUnplug backs up to a file:// destination, then moves the
|
|
// destination directory away, as unplugging the volume it lives on would.
|
|
func backUpThenUnplug(
|
|
ctx context.Context, t *testing.T,
|
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
storeDir := filepath.Join(t.TempDir(), "usbstick")
|
|
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
|
|
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
|
|
return v, repos, out
|
|
}
|
|
|
|
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
|
// a file:// destination whose directory does not exist fails before it
|
|
// records a snapshot, and does not create the directory.
|
|
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
volume := filepath.Join(t.TempDir(), "volume")
|
|
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
|
|
|
err := backUp(v, "first")
|
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
|
|
|
_, err = os.Stat(volume)
|
|
require.ErrorIs(t, err, fs.ErrNotExist,
|
|
"the backup must not create the destination directory")
|
|
|
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, snapshots)
|
|
}
|
|
|
|
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
|
// warns and shows the local index alone, without reporting the local
|
|
// snapshot as missing from the destination.
|
|
func TestListSnapshotsWarnsWhenDestinationMissing(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, out := backUpThenUnplug(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(), "Showing snapshots from the local index only.")
|
|
assert.Contains(t, out.String(), id)
|
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
|
}
|
|
|
|
// TestRemoveSnapshotWarnsWhenDestinationMissing checks that snapshot
|
|
// remove warns that the metadata could not be removed from the
|
|
// destination, instead of reporting that it was.
|
|
func TestRemoveSnapshotWarnsWhenDestinationMissing(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, out := backUpThenUnplug(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.NotContains(t, out.String(),
|
|
"Removed snapshot metadata from remote storage")
|
|
}
|
|
|
|
// TestPruneKeepsLocalRecordsWhenDestinationMissing checks that prune
|
|
// fails on a destination it cannot list and deletes no local snapshot
|
|
// record.
|
|
func TestPruneKeepsLocalRecordsWhenDestinationMissing(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, _ := backUpThenUnplug(ctx, t)
|
|
|
|
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
require.ErrorContains(t, err, "listing remote snapshots")
|
|
|
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
|
}
|
|
|
|
// TestPurgeSaysListingFailedOnceWhenDestinationMissing checks that
|
|
// snapshot purge fails on a destination it cannot list, with an error
|
|
// that says "listing remote snapshots" once.
|
|
func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, _, _ := backUpThenUnplug(ctx, t)
|
|
|
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
KeepLatest: true,
|
|
Force: true,
|
|
})
|
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
|
err.Error())
|
|
}
|