Files
kandev/apps/backend/internal/secrets/postgres_transfer_test.go
T

391 lines
13 KiB
Go

package secrets
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/testutil"
)
// openPGWithConns opens an isolated Postgres schema with a pool of conns
// connections and search_path set per connection (via DSN options), so race
// tests can hold two real connections at once. A one-connection pool trivially
// serializes and proves nothing about the advisory locks.
func openPGWithConns(t *testing.T, conns int) *sqlx.DB {
t.Helper()
dsn := testutil.PostgresDSNFromEnv(t)
schema := "kandev_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
admin, err := sqlx.Open("pgx", dsn)
if err != nil {
t.Fatalf("open postgres: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
_ = admin.Close()
t.Fatalf("create schema: %v", err)
}
_ = admin.Close()
t.Cleanup(func() {
cleanup, cerr := sqlx.Open("pgx", dsn)
if cerr == nil {
_, _ = cleanup.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
_ = cleanup.Close()
}
})
db, err := sqlx.Open("pgx", dsn+" options=-csearch_path="+schema)
if err != nil {
t.Fatalf("open postgres pool: %v", err)
}
db.SetMaxOpenConns(conns)
db.SetMaxIdleConns(conns)
t.Cleanup(func() { _ = db.Close() })
return db
}
// newPGStore builds a secrets store over the given Postgres pool with a fresh master key.
func newPGStore(t *testing.T, db *sqlx.DB) *sqliteStore {
t.Helper()
crypto, err := NewMasterKeyProvider(t.TempDir())
if err != nil {
t.Fatalf("master key: %v", err)
}
store, cleanup, err := Provide(db, db, crypto)
if err != nil {
t.Fatalf("provide store: %v", err)
}
t.Cleanup(func() { _ = cleanup() })
return store
}
// TestPostgresTransfer_CopyMoveConflictAndOwnership verifies Postgres copy/move metadata, name conflicts (including legacy empty-scope globals), and source removal on move.
func TestPostgresTransfer_CopyMoveConflictAndOwnership(t *testing.T) {
db := openPGWithConns(t, 2)
store := newPGStore(t, db)
ctx := context.Background()
mustCreate(t, store, &SecretWithValue{Secret: Secret{ID: "g1", Name: "orig"}, Value: "v"})
// Copy global -> workspace with target identity.
got, err := store.CopyScoped(ctx, "g1", "", ScopeWorkspace, "workspace-a", strPtr("copied"), nil)
if err != nil {
t.Fatalf("CopyScoped: %v", err)
}
if got.Scope != ScopeWorkspace || got.WorkspaceID != "workspace-a" || got.Name != "copied" {
t.Fatalf("copy metadata = %+v", got)
}
if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
t.Fatalf("copy timestamps zero: %+v", got)
}
if value, err := store.RevealForWorkspace(ctx, got.ID, "workspace-a"); err != nil || value != "v" {
t.Fatalf("reveal = %q, %v", value, err)
}
// Conflict on a second copy of the same target name.
if _, err := store.CopyScoped(ctx, "g1", "", ScopeWorkspace, "workspace-a", strPtr("copied"), nil); !errors.Is(err, ErrSecretNameConflict) {
t.Fatalf("conflict err = %v", err)
}
// Move workspace -> global removes the source.
moved, err := store.MoveScoped(ctx, "g1", "", ScopeGlobal, "", strPtr("global-copy"), nil)
if err != nil {
t.Fatalf("MoveScoped: %v", err)
}
if moved.Scope != ScopeGlobal {
t.Fatalf("moved scope = %q", moved.Scope)
}
if _, err := store.Get(ctx, "g1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("source still present: %v", err)
}
// Legacy empty-scope Global row conflicts with a Global target.
ciphertext, nonce, err := Encrypt([]byte("legacy"), store.crypto.Key())
if err != nil {
t.Fatalf("encrypt: %v", err)
}
now := time.Now().UTC()
if _, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO secrets (id, name, user_id, scope, workspace_id, encrypted_value, nonce, created_at, updated_at)
VALUES (?, ?, '', '', '', ?, ?, ?, ?)`),
"legacy-1", "legacy-name", ciphertext, nonce, now, now); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
mustCreate(t, store, &SecretWithValue{Secret: Secret{ID: "g2", Name: "src2"}, Value: "v2"})
if _, err := store.CopyScoped(ctx, "g2", "", ScopeGlobal, "", strPtr("legacy-name"), nil); !errors.Is(err, ErrSecretNameConflict) {
t.Fatalf("legacy conflict err = %v", err)
}
}
// TestPostgresTransfer_MoveRollbackAfterInsert verifies a failed source delete after insert rolls back the copy, leaving the source intact and no destination row.
func TestPostgresTransfer_MoveRollbackAfterInsert(t *testing.T) {
db := openPGWithConns(t, 2)
store := newPGStore(t, db)
ctx := context.Background()
mustCreate(t, store, &SecretWithValue{Secret: Secret{ID: "g1", Name: "orig"}, Value: "v"})
store.failAfterInsert = func() error { return errors.New("injected delete failure") }
if _, err := store.MoveScoped(ctx, "g1", "", ScopeWorkspace, "workspace-a", strPtr("copied"), nil); err == nil {
t.Fatal("MoveScoped succeeded despite failpoint")
}
if _, err := store.Get(ctx, "g1"); err != nil {
t.Fatalf("source lost after rollback: %v", err)
}
items, err := store.ListScoped(ctx, SecretListOptions{Scope: ScopeWorkspace, WorkspaceID: "workspace-a"})
if err != nil || len(items) != 0 {
t.Fatalf("workspace rows = %+v, %v; want none", items, err)
}
}
// TestPostgresTransfer_ConcurrentSameTarget proves exactly one winner for
// concurrent same-name transfers to a Global target, serialized by the Global
// advisory lock.
func TestPostgresTransfer_ConcurrentSameTarget(t *testing.T) {
db := openPGWithConns(t, 3)
storeA := newPGStore(t, db)
storeB := newPGStore(t, db)
ctx := context.Background()
mustCreate(t, storeA, &SecretWithValue{Secret: Secret{ID: "src", Name: "orig"}, Value: "v"})
locked := make(chan struct{})
release := make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := storeA.CopyScoped(ctx, "src", "", ScopeGlobal, "", strPtr("dup"), func(context.Context) error {
close(locked)
<-release
return nil
})
done <- err
}()
<-locked
bErr := make(chan error, 1)
go func() {
_, err := storeB.CopyScoped(ctx, "src", "", ScopeGlobal, "", strPtr("dup"), nil)
bErr <- err
}()
select {
case err := <-bErr:
t.Fatalf("B completed while A held the advisory lock: %v", err)
case <-time.After(300 * time.Millisecond):
}
close(release)
if err := <-done; err != nil {
t.Fatalf("A: %v", err)
}
if err := <-bErr; !errors.Is(err, ErrSecretNameConflict) {
t.Fatalf("B error = %v, want ErrSecretNameConflict", err)
}
}
// TestPostgresTransfer_DeleteVsTransferRace pins both interleavings of the
// shared workspace advisory lock: a transfer that inserts before a deletion
// commits is cleaned up by that deletion, and a deletion that commits first
// leaves nothing for a later transfer to attach to.
func TestPostgresTransfer_DeleteVsTransferRace(t *testing.T) {
db := openPGWithConns(t, 3)
store := newPGStore(t, db)
ctx := context.Background()
mustCreate(t, store, &SecretWithValue{Secret: Secret{ID: "src", Name: "orig"}, Value: "v"})
t.Run("transfer locked before deletion cleanup", func(t *testing.T) {
locked := make(chan struct{})
release := make(chan struct{})
transferDone := make(chan error, 1)
go func() {
_, err := store.CopyScoped(ctx, "src", "", ScopeWorkspace, "workspace-a", strPtr("copied"), func(context.Context) error {
close(locked)
<-release
return nil
})
transferDone <- err
}()
<-locked
delDone := make(chan error, 1)
go func() {
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
delDone <- err
return
}
defer func() { _ = tx.Rollback() }()
if err := store.DeleteWorkspaceSecretsTx(ctx, tx, "workspace-a"); err != nil {
delDone <- err
return
}
delDone <- tx.Commit()
}()
select {
case err := <-delDone:
t.Fatalf("deletion completed while the transfer held the lock: %v", err)
case <-time.After(300 * time.Millisecond):
}
close(release)
if err := <-transferDone; err != nil {
t.Fatalf("transfer: %v", err)
}
if err := <-delDone; err != nil {
t.Fatalf("deletion: %v", err)
}
// The deletion ran after the transfer inserted and removed the copy:
// no secret may remain attached to the deleted workspace.
items, err := store.ListScoped(ctx, SecretListOptions{Scope: ScopeWorkspace, WorkspaceID: "workspace-a"})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(items) != 0 {
t.Fatalf("workspace rows = %+v, want none after deletion", items)
}
})
t.Run("deletion committed before transfer existence check", func(t *testing.T) {
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin deletion: %v", err)
}
if err := store.DeleteWorkspaceSecretsTx(ctx, tx, "workspace-b"); err != nil {
_ = tx.Rollback()
t.Fatalf("delete: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit deletion: %v", err)
}
// The transfer's existence check (the callback, run under the lock)
// denies a workspace that no longer exists; nothing is inserted.
denied := errors.New("workspace gone")
_, err = store.CopyScoped(ctx, "src", "", ScopeWorkspace, "workspace-b", strPtr("copied"), func(context.Context) error {
return denied
})
if !errors.Is(err, denied) {
t.Fatalf("err = %v, want the denied callback error", err)
}
items, err := store.ListScoped(ctx, SecretListOptions{Scope: ScopeWorkspace, WorkspaceID: "workspace-b"})
if err != nil || len(items) != 0 {
t.Fatalf("workspace rows = %+v, %v; want none", items, err)
}
})
}
// TestPostgresTransfer_MoveSerializesConcurrentUpdate proves the Move source
// row is locked with FOR UPDATE: a concurrent Update cannot interleave between
// Move's read and delete, so no newer value is lost.
func TestPostgresTransfer_MoveSerializesConcurrentUpdate(t *testing.T) {
db := openPGWithConns(t, 3)
store := newPGStore(t, db)
ctx := context.Background()
mustCreate(t, store, &SecretWithValue{Secret: Secret{ID: "src", Name: "orig"}, Value: "A"})
locked := make(chan struct{})
release := make(chan struct{})
moveDone := make(chan error, 1)
store.afterSourceLock = func() error {
close(locked)
<-release
return nil
}
go func() {
_, err := store.MoveScoped(ctx, "src", "", ScopeWorkspace, "workspace-a", strPtr("copied"), nil)
moveDone <- err
}()
// Move holds the FOR UPDATE source row lock.
<-locked
updateDone := make(chan error, 1)
go func() {
updateDone <- store.Update(ctx, "src", &UpdateSecretRequest{Value: stringPtr("B")})
}()
select {
case err := <-updateDone:
t.Fatalf("Update completed while Move held the source row lock: %v", err)
case <-time.After(300 * time.Millisecond):
// Blocked on the row lock, as intended.
}
close(release)
if err := <-moveDone; err != nil {
t.Fatalf("move: %v", err)
}
// The Update completes after Move commits; the row is gone, so it touches
// nothing and the newer value B is not lost anywhere.
if err := <-updateDone; err != nil {
t.Fatalf("update: %v", err)
}
if _, err := store.Get(ctx, "src"); !errors.Is(err, ErrNotFound) {
t.Fatalf("source still present after move: %v", err)
}
items, err := store.ListScoped(ctx, SecretListOptions{Scope: ScopeWorkspace, WorkspaceID: "workspace-a"})
if err != nil {
t.Fatalf("list workspace: %v", err)
}
var moved *SecretListItem
for _, item := range items {
if item.Name == "copied" {
moved = item
}
}
if moved == nil {
t.Fatalf("moved copy not found in workspace-a: %+v", items)
}
value, err := store.RevealForWorkspace(ctx, moved.ID, "workspace-a")
if err != nil {
t.Fatalf("reveal moved: %v", err)
}
if value != "A" {
t.Fatalf("moved value = %q, want A (concurrent update must not be lost)", value)
}
}
// TestPostgresTransfer_ConditionalInsertRejectsLateConflict verifies the
// transfer insert is guarded by an atomic NOT EXISTS: an ordinary create or
// rename (which does not take the transfer's advisory lock) committing the
// target name between the eager conflict check and the insert must produce a
// clean ErrSecretNameConflict, never a duplicate row.
func TestPostgresTransfer_ConditionalInsertRejectsLateConflict(t *testing.T) {
db := openPGWithConns(t, 2)
store := newPGStore(t, db)
ctx := context.Background()
mustCreate(t, store, &SecretWithValue{Secret: Secret{ID: "g1", Name: "orig"}, Value: "v"})
// Commit the target name from a second connection at the exact point
// between the transfer's conflict check and its conditional insert.
store.beforeTransferInsert = func() error {
_, err := db.Exec(`
INSERT INTO secrets (id, name, user_id, scope, workspace_id, encrypted_value, nonce, created_at, updated_at)
VALUES ('late', 'copied', '', 'workspace', 'workspace-a', '\x01'::bytea, '\x01'::bytea, now(), now())`)
return err
}
_, err := store.CopyScoped(ctx, "g1", "", ScopeWorkspace, "workspace-a", strPtr("copied"), nil)
if !errors.Is(err, ErrSecretNameConflict) {
t.Fatalf("error = %v, want ErrSecretNameConflict (atomic NOT EXISTS must observe the late row)", err)
}
// Exactly one 'copied' row in workspace-a: the late create won, the
// transfer did not insert a duplicate.
items, err := store.ListScoped(ctx, SecretListOptions{Scope: ScopeWorkspace, WorkspaceID: "workspace-a"})
if err != nil {
t.Fatalf("list: %v", err)
}
count := 0
for _, item := range items {
if item.Name == "copied" {
count++
}
}
if count != 1 {
t.Fatalf("'copied' rows in workspace-a = %d, want 1 (no duplicate from the transfer)", count)
}
}