Files
vaultik/internal/vaultik/storage_bind_test.go
T
clawbot c07db21b45
check / check (push) Successful in 9m13s
Bind the local index only after a backup writes the marker (closes #420)
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
2026-11-11 04:49:11 +01:00

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")
})
}
}