check / check (push) Successful in 9m13s
A first backup that failed before writing anything, on a missing bucket, a refused credential or missing recipients, still bound the local index to the configured destination, so correcting a mistyped storage_url was refused afterwards. A backup now records the binding only after it has written the marker file at the destination. prune, snapshot purge, snapshot remove and remote nuke only refuse a different binding and leave an unbound index unbound. Model: opus-5-5
245 lines
7.1 KiB
Go
245 lines
7.1 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"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"
|
|
)
|
|
|
|
// storageBindingOf returns the destination the local index is bound to,
|
|
// or "" when it is not bound.
|
|
func storageBindingOf(
|
|
ctx context.Context, t *testing.T, repos *database.Repositories,
|
|
) string {
|
|
t.Helper()
|
|
|
|
got, err := repos.LocalMeta.Get(ctx, database.LocalMetaKeyStorageURL)
|
|
require.NoError(t, err)
|
|
|
|
return got
|
|
}
|
|
|
|
// TestFailedFirstBackupLeavesIndexUnbound checks that a first backup
|
|
// the destination refuses, as a mistyped bucket would, leaves the local
|
|
// index unbound, so a backup with the corrected destination succeeds.
|
|
func TestFailedFirstBackupLeavesIndexUnbound(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
storeDir := t.TempDir()
|
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
working := v.Storage
|
|
|
|
refusing := faultstore.New(working)
|
|
refusing.OnPut = func(string) faultstore.PutAction { return faultstore.PutFail }
|
|
refusing.OnStat = func(string) bool { return true }
|
|
v.Storage = refusing
|
|
v.Config.StorageURL = "file://" + filepath.Join(t.TempDir(), "typo")
|
|
|
|
require.ErrorIs(t, backUp(v, "first"), faultstore.ErrInjectedUpload)
|
|
assert.Empty(t, storageBindingOf(ctx, t, repos))
|
|
|
|
v.Storage = working
|
|
v.Config.StorageURL = "file://" + storeDir
|
|
|
|
require.NoError(t, backUp(v, "first"))
|
|
assert.Equal(t, "file://"+storeDir, storageBindingOf(ctx, t, repos))
|
|
}
|
|
|
|
// TestBackupToOtherDestinationWritesNoMarker checks that a backup
|
|
// refuses a local index bound to a different destination before it
|
|
// writes the marker to the configured one.
|
|
func TestBackupToOtherDestinationWritesNoMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
firstDir := t.TempDir()
|
|
v, _ := newFileDestinationVaultik(ctx, t, firstDir)
|
|
v.Config.StorageURL = "file://" + firstDir
|
|
|
|
require.NoError(t, backUp(v, "first"))
|
|
|
|
secondDir := t.TempDir()
|
|
secondStore, err := storage.NewFileStorer(secondDir)
|
|
require.NoError(t, err)
|
|
|
|
v.Storage = secondStore
|
|
v.Config.StorageURL = "file://" + secondDir
|
|
|
|
require.ErrorContains(t, backUp(v, "first"),
|
|
"local index is bound to a different backup destination")
|
|
assert.NoFileExists(t, filepath.Join(secondDir, markerFile))
|
|
}
|
|
|
|
// TestCommandsOtherThanBackupLeaveIndexUnbound checks that commands
|
|
// other than a backup do not bind an unbound local index, whether or not
|
|
// they fail. Each runs on a fresh local index.
|
|
func TestCommandsOtherThanBackupLeaveIndexUnbound(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
run func(v *vaultik.Vaultik) error
|
|
}{
|
|
{"prune", func(v *vaultik.Vaultik) error {
|
|
return v.Prune(&vaultik.PruneOptions{Force: true})
|
|
}},
|
|
{"snapshot purge", func(v *vaultik.Vaultik) error {
|
|
return v.PurgeSnapshotsWithOptions(
|
|
&vaultik.SnapshotPurgeOptions{KeepLatest: true, Force: true})
|
|
}},
|
|
{"snapshot remove", func(v *vaultik.Vaultik) error {
|
|
_, err := v.RemoveSnapshot("host_first_2026-01-02T03:04:05Z",
|
|
&vaultik.RemoveOptions{Force: true})
|
|
|
|
return err
|
|
}},
|
|
{"snapshot remove --all", func(v *vaultik.Vaultik) error {
|
|
_, err := v.RemoveAllSnapshots(&vaultik.RemoveOptions{Force: true})
|
|
|
|
return err
|
|
}},
|
|
{"remote nuke", func(v *vaultik.Vaultik) error {
|
|
return v.NukeRemote(true)
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos := newFileDestinationVaultik(ctx, t, t.TempDir())
|
|
|
|
_ = tt.run(v)
|
|
|
|
assert.Empty(t, storageBindingOf(ctx, t, repos))
|
|
})
|
|
}
|
|
}
|
|
|
|
// buildBindTestVaultik returns a minimal Vaultik wired with a real
|
|
// in-memory DB and a config carrying the given StorageURL. Enough to
|
|
// exercise EnsureStorageBinding without spinning up storage or fx.
|
|
func buildBindTestVaultik(
|
|
t *testing.T, storageURL string,
|
|
) (*vaultik.TestVaultik, *database.Repositories) {
|
|
t.Helper()
|
|
|
|
db, err := database.NewTestDB()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
tv := vaultik.NewForTesting(nil)
|
|
tv.Repositories = repos
|
|
tv.DB = db
|
|
tv.Config = &config.Config{StorageURL: storageURL}
|
|
tv.SetContext(context.Background())
|
|
|
|
return tv, repos
|
|
}
|
|
|
|
func TestEnsureStorageBinding_FreshDBRecordsURL(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
tv, repos := buildBindTestVaultik(t, "file:///mnt/backups/new")
|
|
|
|
require.NoError(t, tv.EnsureStorageBinding())
|
|
|
|
got, err := repos.LocalMeta.Get(context.Background(), database.LocalMetaKeyStorageURL)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "file:///mnt/backups/new", got,
|
|
"first call must record the configured URL")
|
|
}
|
|
|
|
func TestEnsureStorageBinding_MatchingURLPasses(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
tv, repos := buildBindTestVaultik(t, "s3://bucket/prefix")
|
|
|
|
require.NoError(t, repos.LocalMeta.Set(context.Background(),
|
|
database.LocalMetaKeyStorageURL, "s3://bucket/prefix"))
|
|
|
|
require.NoError(t, tv.EnsureStorageBinding())
|
|
}
|
|
|
|
func TestEnsureStorageBinding_MismatchRefuses(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
tv, repos := buildBindTestVaultik(t, "file:///mnt/backups/new")
|
|
|
|
require.NoError(t, repos.LocalMeta.Set(context.Background(),
|
|
database.LocalMetaKeyStorageURL, "file:///mnt/backups/old"))
|
|
|
|
err := tv.EnsureStorageBinding()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "file:///mnt/backups/old")
|
|
assert.Contains(t, err.Error(), "file:///mnt/backups/new")
|
|
assert.Contains(t, err.Error(), "vaultik database delete")
|
|
}
|
|
|
|
// A config with no storage_url names its destination with s3.endpoint,
|
|
// s3.bucket and s3.prefix; a change to any of them must be refused just
|
|
// as a changed storage_url is.
|
|
func TestEnsureStorageBinding_S3FieldsChangeRefuses(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
bound := config.S3Config{
|
|
Endpoint: "https://s3.example.com",
|
|
Bucket: "bucket-a",
|
|
Prefix: "backups",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
changed config.S3Config
|
|
}{
|
|
{"bucket", config.S3Config{
|
|
Endpoint: bound.Endpoint, Bucket: "bucket-b", Prefix: bound.Prefix,
|
|
}},
|
|
{"prefix", config.S3Config{
|
|
Endpoint: bound.Endpoint, Bucket: bound.Bucket, Prefix: "other",
|
|
}},
|
|
{"endpoint", config.S3Config{
|
|
Endpoint: "https://s3.other.com", Bucket: bound.Bucket, Prefix: bound.Prefix,
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tv, _ := buildBindTestVaultik(t, "")
|
|
|
|
tv.Config.S3 = bound
|
|
require.NoError(t, tv.EnsureStorageBinding(), "first use binds")
|
|
require.NoError(t, tv.EnsureStorageBinding(), "same destination passes")
|
|
|
|
tv.Config.S3 = tt.changed
|
|
err := tv.EnsureStorageBinding()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(),
|
|
"local index is bound to a different backup destination")
|
|
assert.Contains(t, err.Error(), "vaultik database delete")
|
|
})
|
|
}
|
|
}
|