mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
1b326cb00e
Explicit-ID backup restores previously left PostgreSQL sequences behind, causing immediate creates to collide until restart. Resolve the active ID sequence and advance it with transactional RESTART while preserving allocated values, caches and increments. Take the complete import write-table lock set with NOWAIT and retry only pre-write contention, avoiding lock-order cycles with existing CRUD transactions. Cover public API/repository restores, all explicit-ID tables, legacy schemas, concurrent creates/imports/cascade deletes, and rollback paths on isolated PostgreSQL.
555 lines
21 KiB
Go
555 lines
21 KiB
Go
package contract_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go-backend/internal/auth"
|
|
httpserver "go-backend/internal/http"
|
|
"go-backend/internal/http/handler"
|
|
"go-backend/internal/security"
|
|
"go-backend/internal/store/model"
|
|
"go-backend/internal/store/repo"
|
|
)
|
|
|
|
func TestPostgresBackupImportAllowsImmediateCreate(t *testing.T) {
|
|
source := ownerNamesRepository(t, "postgres")()
|
|
for _, name := range []string{"backup-one", "backup-two"} {
|
|
if _, err := source.CreateUser(name, security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := source.CreateSpeedLimit(name, 100, 0, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
backup, err := source.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := ownerNamesRepository(t, "postgres")()
|
|
if _, err := target.Import(backup, []string{"users", "speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id, err := target.CreateUser("after-import", security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil || id <= 2 {
|
|
t.Errorf("create user immediately after backup import: id=%d err=%v", id, err)
|
|
}
|
|
if id, err := target.CreateSpeedLimit("after-import", 100, 0, 1); err != nil || id <= 2 {
|
|
t.Errorf("create speed limit immediately after backup import: id=%d err=%v", id, err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportPreservesAllocatedIDs(t *testing.T) {
|
|
for _, called := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("called=%t", called), func(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
// A deleted or rolled-back allocation may be higher than every row.
|
|
for _, table := range []string{"user", "speed_limit"} {
|
|
if err := r.DB().Exec(`SELECT setval(pg_get_serial_sequence(?, 'id'), 500, ?)`, `"`+table+`"`, called).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
b := &model.BackupData{Users: []model.UserBackup{{ID: 1, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "restored"}}}
|
|
if _, err := r.Import(b, []string{"users", "speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := int64(500)
|
|
if called {
|
|
want++
|
|
}
|
|
if id, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != want {
|
|
t.Fatalf("user id=%d, want %d; err=%v", id, want, err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != want {
|
|
t.Fatalf("speed limit id=%d, want %d; err=%v", id, want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportInvalidatesCachedIDs(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq CACHE 20`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pool, err := r.DB().DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cached, err := pool.Conn(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cached.Close()
|
|
// Hold this backend out of the pool with unused cached IDs 2..20.
|
|
var allocated int64
|
|
if err := cached.QueryRowContext(context.Background(), `SELECT nextval('speed_limit_id_seq')`).Scan(&allocated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if allocated != 1 {
|
|
t.Fatal(allocated)
|
|
}
|
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 2, Name: "restored"}}}
|
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Return only the cached backend to the pool, so the public create must use it.
|
|
pool.SetMaxIdleConns(0)
|
|
pool.SetMaxIdleConns(1)
|
|
if err := cached.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id <= 20 {
|
|
t.Fatalf("cached ID reused: id=%d err=%v", id, err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportRollsBackSequenceOnCommitFailure(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
// Deferred validation fails after import has already advanced the sequence.
|
|
for _, query := range []string{
|
|
`CREATE FUNCTION reject_import_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'reject imported row at commit'; END $$`,
|
|
`CREATE CONSTRAINT TRIGGER reject_import_commit AFTER INSERT ON speed_limit DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name = 'fail-at-commit') EXECUTE FUNCTION reject_import_commit()`,
|
|
} {
|
|
if err := r.DB().Exec(query).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "fail-at-commit"}}}
|
|
if _, err := r.Import(b, []string{"speedLimits"}); err == nil || !strings.Contains(err.Error(), "reject imported row at commit") {
|
|
t.Fatalf("expected commit failure, got %v", err)
|
|
}
|
|
exported, err := r.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exported.SpeedLimits) != 0 {
|
|
t.Fatal("failed import persisted rows")
|
|
}
|
|
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
|
t.Fatalf("failed import changed next allocation: id=%d err=%v", id, err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportPreservesSequenceIncrement(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq INCREMENT BY 10`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 25, Name: "restored"}}}
|
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 31 {
|
|
t.Fatalf("changed ID progression: id=%d, want 31; err=%v", id, err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportAdvancesEveryExplicitIDTable(t *testing.T) {
|
|
source := ownerNamesRepository(t, "postgres")()
|
|
types := []string{"users", "speedLimits", "nodes", "tunnels", "forwards", "userTunnels", "tunnelGroups", "userGroups", "permissions", "configs"}
|
|
fixture := &model.BackupData{
|
|
Users: []model.UserBackup{{ID: 1, User: "old", Pwd: "$2-test-fixture", RoleID: 1}},
|
|
SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "old"}},
|
|
Nodes: []model.NodeBackup{{ID: 1, Name: "old", Secret: "old-node"}},
|
|
Tunnels: []model.TunnelBackup{{ID: 1, Name: "old", ChainTunnels: []model.ChainTunnelBackup{{ID: 1, TunnelID: 1, NodeID: 1, ChainType: "1"}}}},
|
|
Forwards: []model.ForwardBackup{{ID: 1, Name: "old", UserID: 1, TunnelID: 1}},
|
|
UserTunnels: []model.UserTunnelBackup{{ID: 1, UserID: 1, TunnelID: 1}},
|
|
TunnelGroups: []model.TunnelGroupBackup{{ID: 1, Name: "old"}},
|
|
UserGroups: []model.UserGroupBackup{{ID: 1, Name: "old"}},
|
|
Permissions: []model.PermissionBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, Grants: []model.PermissionGrantBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, UserTunnelID: 1}}}},
|
|
}
|
|
if _, err := source.Import(fixture, types); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backup, err := source.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
if _, err := r.Import(backup, types); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Use the normal public creation methods, including the nested child tables.
|
|
for _, create := range []struct {
|
|
name string
|
|
run func() error
|
|
}{
|
|
{"user", func() error { _, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); return err }},
|
|
{"speed limit", func() error { _, err := r.CreateSpeedLimit("new", 100, 0, 1); return err }},
|
|
{"node", func() error {
|
|
return r.CreateRemoteNode("new", "new-node", "127.0.0.1", "10000-20000", 0, 1, 0, "", "", "")
|
|
}},
|
|
{"tunnel", func() error { _, err := r.CreateTunnelTx(r.DB(), "new", 1, 1, 0, 0, 1, nil, 0, "", "", 0); return err }},
|
|
{"chain tunnel", func() error { return r.CreateChainTunnelTx(r.DB(), 1, "1", 2, sql.NullInt64{}, "", 1, "tls", "") }},
|
|
{"forward", func() error {
|
|
_, err := r.CreateForwardTx(1, "old", "new", 1, "127.0.0.1:80", "", 0, 0, nil, 0, "", nil, 0, 0, nil, 0, 0, 0)
|
|
return err
|
|
}},
|
|
{"user tunnel", func() error { return r.InsertUserTunnel(2, 1, nil, 0, 0, 0, 0, 1) }},
|
|
{"tunnel group", func() error { return r.GroupCreate("tunnel_group", "new", 1, 0) }},
|
|
{"user group", func() error { return r.GroupCreate("user_group", "new", 1, 0) }},
|
|
{"permission", func() error { return r.InsertGroupPermission(2, 2, 0) }},
|
|
{"permission grant", func() error { r.InsertGroupPermissionGrant(1, 1, 2, 1, 0); return nil }},
|
|
} {
|
|
t.Run(create.name, func(t *testing.T) {
|
|
if err := create.run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
got, err := r.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids := map[string][]int64{}
|
|
for _, v := range got.Users {
|
|
ids["user"] = append(ids["user"], v.ID)
|
|
}
|
|
for _, v := range got.SpeedLimits {
|
|
ids["speed limit"] = append(ids["speed limit"], v.ID)
|
|
}
|
|
for _, v := range got.Nodes {
|
|
ids["node"] = append(ids["node"], v.ID)
|
|
}
|
|
for _, v := range got.Tunnels {
|
|
ids["tunnel"] = append(ids["tunnel"], v.ID)
|
|
for _, child := range v.ChainTunnels {
|
|
ids["chain tunnel"] = append(ids["chain tunnel"], child.ID)
|
|
}
|
|
}
|
|
for _, v := range got.Forwards {
|
|
ids["forward"] = append(ids["forward"], v.ID)
|
|
}
|
|
for _, v := range got.UserTunnels {
|
|
ids["user tunnel"] = append(ids["user tunnel"], v.ID)
|
|
}
|
|
for _, v := range got.TunnelGroups {
|
|
ids["tunnel group"] = append(ids["tunnel group"], v.ID)
|
|
}
|
|
for _, v := range got.UserGroups {
|
|
ids["user group"] = append(ids["user group"], v.ID)
|
|
}
|
|
for _, v := range got.Permissions {
|
|
ids["permission"] = append(ids["permission"], v.ID)
|
|
for _, child := range v.Grants {
|
|
ids["permission grant"] = append(ids["permission grant"], child.ID)
|
|
}
|
|
}
|
|
if len(ids) != 11 {
|
|
t.Fatalf("missing tables in export: %v", ids)
|
|
}
|
|
for name, values := range ids {
|
|
slices.Sort(values)
|
|
if !slices.Equal(values, []int64{1, 2}) {
|
|
t.Errorf("%s: expected old and new IDs [1 2], got %v", name, values)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportKeepsExistingRowsAndEmptySelections(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
// Fixture simulates an existing explicit-ID row with an unsynchronized sequence.
|
|
if err := r.DB().Create(&model.SpeedLimit{ID: 500, Name: "existing"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
|
if _, err := r.Import(old, []string{"speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 501 {
|
|
t.Fatalf("existing maximum ignored: id=%d err=%v", id, err)
|
|
}
|
|
for _, b := range []*model.BackupData{{}, {Users: []model.UserBackup{{ID: 900, User: "not-selected", Pwd: "$2-test-fixture"}}}} {
|
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if id, err := r.CreateUser("first-user", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
|
t.Fatalf("unselected sequence changed: id=%d err=%v", id, err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("after-empty", 100, 0, 1); err != nil || id != 502 {
|
|
t.Fatalf("empty import changed allocation: id=%d err=%v", id, err)
|
|
}
|
|
got, err := r.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Users) != 2 || len(got.SpeedLimits) != 4 || got.SpeedLimits[1].Name != "existing" {
|
|
t.Fatalf("existing or unselected data changed: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportResolvesLegacySequenceAndSearchPath(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
pool, err := r.DB().DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pool.SetMaxOpenConns(1)
|
|
var schema string
|
|
if err := r.DB().Raw(`SELECT current_schema()`).Scan(&schema).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prefix := "Empty-Prefix-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
if err := r.DB().Exec(`CREATE SCHEMA "` + prefix + `"`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = r.DB().Exec(`DROP SCHEMA "` + prefix + `" CASCADE`).Error })
|
|
// The historical default uses a custom, unowned sequence; the original
|
|
// owned serial sequence still exists but is no longer used by INSERT.
|
|
for _, query := range []string{
|
|
`CREATE SEQUENCE "Custom Limit Sequence"`,
|
|
`ALTER TABLE speed_limit ALTER COLUMN id SET DEFAULT nextval('"Custom Limit Sequence"'::regclass)`,
|
|
`SET search_path TO "` + prefix + `", "` + schema + `"`,
|
|
} {
|
|
if err := r.DB().Exec(query).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 2 {
|
|
t.Fatalf("wrong sequence or schema: id=%d err=%v", id, err)
|
|
}
|
|
}
|
|
|
|
func waitForBackupDBCondition(t *testing.T, r *repo.Repository, query string, args ...interface{}) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
var ready bool
|
|
if err := r.DB().Raw(query, args...).Scan(&ready).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ready {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("database barrier was not reached")
|
|
}
|
|
|
|
func backupImportBarrier(t *testing.T, r *repo.Repository, table, timing, event, condition string) (*sql.Tx, int64) {
|
|
t.Helper()
|
|
pool, err := r.DB().DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
barrier, err := pool.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = barrier.Rollback() })
|
|
key := time.Now().UnixNano() & 0x3fffffff
|
|
if _, err := barrier.Exec(`SELECT pg_advisory_xact_lock($1)`, key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
guardEnd := ""
|
|
if condition != "" {
|
|
guardEnd = "END IF;"
|
|
}
|
|
function := fmt.Sprintf(`CREATE FUNCTION pause_backup_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN %s PERFORM pg_advisory_xact_lock(%d); %s RETURN NEW; END $$`, condition, key, guardEnd)
|
|
if err := r.DB().Exec(function).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
trigger := fmt.Sprintf(`CREATE TRIGGER pause_backup_test %s %s ON %s FOR EACH ROW EXECUTE FUNCTION pause_backup_test()`, timing, event, table)
|
|
if err := r.DB().Exec(trigger).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return barrier, key
|
|
}
|
|
|
|
func TestPostgresBackupImportSerializesConcurrentCreatesAndImports(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
barrier, key := backupImportBarrier(t, r, "speed_limit", "BEFORE", "INSERT", "IF NEW.name = 'hold-import' THEN")
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
_, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "hold-import"}}}, []string{"speedLimits"})
|
|
first <- err
|
|
}()
|
|
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
|
created := make(chan error, 1)
|
|
go func() {
|
|
id, err := r.CreateSpeedLimit("concurrent-create", 100, 0, 1)
|
|
if err == nil && id <= 100 {
|
|
err = fmt.Errorf("reused imported ID: %d", id)
|
|
}
|
|
created <- err
|
|
}()
|
|
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='speed_limit'::regclass AND NOT granted)`)
|
|
second := make(chan error, 1)
|
|
go func() {
|
|
result, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 200, Name: "second-import"}}}, []string{"speedLimits"})
|
|
if err == nil && result.SpeedLimitsImported != 1 {
|
|
err = fmt.Errorf("wrong retry count: %+v", result)
|
|
}
|
|
second <- err
|
|
}()
|
|
if err := barrier.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, done := range map[string]<-chan error{"first import": first, "create": created, "second import": second} {
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("%s blocked", name)
|
|
}
|
|
}
|
|
if id, err := r.CreateSpeedLimit("after-both", 100, 0, 1); err != nil || id <= 200 {
|
|
t.Fatalf("second import sequence not advanced: id=%d err=%v", id, err)
|
|
}
|
|
got, err := r.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.SpeedLimits) != 4 {
|
|
t.Fatalf("lost concurrent rows: %+v", got.SpeedLimits)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportAvoidsCascadeDeleteDeadlock(t *testing.T) {
|
|
for _, mode := range []string{"retry-success", "busy-bounded"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
user, err := r.CreateUser("delete-me", "hash", 1, 0, 0, 0, 0, 1, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forward, err := r.CreateForwardTx(user, "delete-me", "old", 1, "127.0.0.1:80", "", 0, 0, []int64{1}, 12345, "", nil, 0, 0, nil, 0, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Pause a real public cascade after deleting a child row, before it
|
|
// reaches forward/user. Import must not retain partial parent locks.
|
|
barrier, key := backupImportBarrier(t, r, "forward_port", "AFTER", "DELETE", "")
|
|
deleted := make(chan error, 1)
|
|
go func() { deleted <- r.DeleteUserCascade(user) }()
|
|
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
|
emptyPorts := []model.ForwardPortBackup{}
|
|
backup := &model.BackupData{
|
|
Users: []model.UserBackup{{ID: 100, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}},
|
|
Forwards: []model.ForwardBackup{{ID: forward, UserID: 100, Name: "restored", ForwardPorts: &emptyPorts}},
|
|
}
|
|
imported := make(chan error, 1)
|
|
runImport := func() {
|
|
result, err := r.Import(backup, []string{"users", "forwards"})
|
|
if err == nil && (result.UsersImported != 1 || result.ForwardsImported != 1) {
|
|
err = fmt.Errorf("wrong retry count: %+v", result)
|
|
}
|
|
imported <- err
|
|
}
|
|
go runImport()
|
|
if mode == "busy-bounded" {
|
|
select {
|
|
case err := <-imported:
|
|
if err == nil || !strings.Contains(err.Error(), "database is busy") {
|
|
t.Fatalf("expected bounded busy error, got %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
_ = barrier.Rollback()
|
|
t.Fatal("import waited while holding partial table locks")
|
|
}
|
|
} else {
|
|
// The held child lock makes every attempt fail before writes.
|
|
// Let several bounded attempts encounter it before release.
|
|
time.Sleep(75 * time.Millisecond)
|
|
}
|
|
if err := barrier.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-deleted:
|
|
if err != nil {
|
|
t.Fatalf("cascade: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("cascade deadlocked")
|
|
}
|
|
if mode == "busy-bounded" {
|
|
go runImport()
|
|
}
|
|
select {
|
|
case err := <-imported:
|
|
if err != nil {
|
|
t.Fatalf("import: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("import deadlocked")
|
|
}
|
|
if id, err := r.CreateUser("after", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id <= 100 {
|
|
t.Fatalf("create after concurrent cascade: id=%d err=%v", id, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportRollsBackEarlierSequenceWhenLaterSequenceFails(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq MAXVALUE 10`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backup := &model.BackupData{
|
|
Users: []model.UserBackup{{ID: 100, User: "rollback-user", Pwd: "$2-test-fixture", RoleID: 1}},
|
|
SpeedLimits: []model.SpeedLimitBackup{{ID: 10, Name: "exhausted"}},
|
|
}
|
|
// User sequence advances first. The exhausted limit sequence then fails.
|
|
if _, err := r.Import(backup, []string{"users", "speedLimits"}); err == nil || !strings.Contains(err.Error(), "sequence exhausted") {
|
|
t.Fatalf("expected sequence exhaustion: %v", err)
|
|
}
|
|
if id, err := r.CreateUser("after-failure", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
|
t.Fatalf("earlier sequence failed to roll back: id=%d err=%v", id, err)
|
|
}
|
|
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
|
t.Fatalf("later sequence changed: id=%d err=%v", id, err)
|
|
}
|
|
got, err := r.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Users) != 2 || len(got.SpeedLimits) != 1 {
|
|
t.Fatalf("partial import committed: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPostgresBackupImportAPIAllowsImmediateCreate(t *testing.T) {
|
|
r := ownerNamesRepository(t, "postgres")()
|
|
const secret = "backup-sequence-contract"
|
|
router := httpserver.NewRouter(handler.New(r, secret), secret)
|
|
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
post := func(path, body string) {
|
|
t.Helper()
|
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
request.Header.Set("Authorization", token)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
assertCode(t, response, 0)
|
|
}
|
|
post("/api/v1/backup/import", `{"version":"1.0","types":["users","speedLimits"],"users":[{"id":2,"user":"restored","pwd":"$2-test-fixture","roleId":1,"status":1}],"speedLimits":[{"id":1,"name":"restored","speed":100,"status":1}]}`)
|
|
post("/api/v1/user/create", `{"user":"after-import","pwd":"test-only-password","status":1}`)
|
|
post("/api/v1/speed-limit/create", `{"name":"after-import","speed":200}`)
|
|
got, err := r.ExportAll()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Users) != 3 || got.Users[2].ID != 3 || len(got.SpeedLimits) != 2 || got.SpeedLimits[1].ID != 2 {
|
|
t.Fatalf("API create did not retain restored records: %+v", got)
|
|
}
|
|
}
|