//go:build integration package inttest import ( "context" "database/sql" "errors" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/pressly/goose/v3" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" "scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres/migrations" ) // isPgCode reports whether err is a PostgreSQL server error with the given // SQLSTATE code. func isPgCode(err error, code string) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == code } // TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed // seed rows are present after migration. func TestPaymentsSchemaAndSeeds(t *testing.T) { ctx := context.Background() var atoms int if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil { t.Fatalf("count catalog_atom: %v", err) } if atoms != 4 { t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms) } var cfg int if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil { t.Fatalf("count config: %v", err) } if cfg != 1 { t.Errorf("config rows = %d, want the single seeded row", cfg) } var roleExists bool if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil { t.Fatalf("check payments role: %v", err) } if !roleExists { t.Error("payments role missing") } } // TestPaymentsRoleConfinement proves the payments role is confined to its own // schema: it can read payments.* but is denied on backend.*. The application // itself connects as a superuser (which bypasses this), so this asserts the // grants are correct as the stepping-stone to a real separate login; the runtime // wall for the superuser pool is the import-boundary test in the payments package. func TestPaymentsRoleConfinement(t *testing.T) { ctx := context.Background() tx, err := testDB.BeginTx(ctx, nil) if err != nil { t.Fatalf("begin tx: %v", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil { t.Fatalf("SET LOCAL ROLE payments: %v", err) } // The payments role can read its own schema. var n int if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil { t.Errorf("payments role denied on payments.config (grants wrong): %v", err) } // The payments role must NOT reach the backend schema. err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n) if err == nil { t.Fatal("payments role could read backend.accounts — cross-schema isolation broken") } if !isPgCode(err, "42501") { t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err) } } // TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any // UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a // mere privilege REVOKE). func TestPaymentsLedgerAppendOnly(t *testing.T) { ctx := context.Background() id := uuid.New() if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`, id, uuid.New()); err != nil { t.Fatalf("insert ledger row: %v", err) } if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil { t.Error("UPDATE on payments.ledger succeeded — append-only violated") } else if !isPgCode(err, "P0001") { t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err) } if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil { t.Error("DELETE on payments.ledger succeeded — append-only violated") } else if !isPgCode(err, "P0001") { t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err) } } // TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative // balances/hints/amount and the enum-like columns. func TestPaymentsCheckConstraints(t *testing.T) { ctx := context.Background() prod := uuid.New() if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil { t.Fatalf("insert product: %v", err) } cases := []struct { name string sql string args []any }{ {"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}}, {"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}}, {"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}}, {"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}}, {"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}}, {"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}}, } for _, c := range cases { if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil { t.Errorf("%s: insert succeeded, want a CHECK violation", c.name) } else if !isPgCode(err, "23514") { t.Errorf("%s: got %v, want check_violation (23514)", c.name, err) } } } // TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a // (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows // unconstrained. func TestPaymentsLedgerIdempotencyIndex(t *testing.T) { ctx := context.Background() insert := func(ppid *string) error { _, err := testDB.ExecContext(ctx, `INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`, uuid.New(), uuid.New(), ppid) return err } ppid := "inv-" + uuid.NewString() if err := insert(&ppid); err != nil { t.Fatalf("first insert: %v", err) } if err := insert(&ppid); err == nil { t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing") } else if !isPgCode(err, "23505") { t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err) } // A NULL provider_payment_id is outside the partial index — repeats allowed. if err := insert(nil); err != nil { t.Fatalf("first null-ppid insert: %v", err) } if err := insert(nil); err != nil { t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err) } } // TestPaymentsMigrationReversible proves the migration is expand-contract: it // applies, rolls fully back (schema, role, trigger and all), and re-applies // cleanly — so a backend image rollback stays DB-safe. It uses its own container // so the Down does not disturb the shared suite database. func TestPaymentsMigrationReversible(t *testing.T) { ctx := context.Background() container, err := tcpostgres.Run(ctx, pgImage, tcpostgres.WithDatabase(pgDatabase), tcpostgres.WithUsername(pgUser), tcpostgres.WithPassword(pgPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(containerStartup), ), ) if err != nil { t.Fatalf("start container: %v", err) } defer func() { _ = container.Terminate(context.Background()) }() baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("connection string: %v", err) } dsn, err := withSearchPath(baseDSN, pgSchema) if err != nil { t.Fatalf("search path: %v", err) } cfg := postgres.DefaultConfig() cfg.DSN = dsn db, err := postgres.Open(ctx, cfg) if err != nil { t.Fatalf("open pool: %v", err) } defer func() { _ = db.Close() }() // Up: apply every migration, including the payments foundation. if err := postgres.ApplyMigrations(ctx, db); err != nil { t.Fatalf("apply up: %v", err) } if !schemaExists(ctx, t, db, "payments") { t.Fatal("payments schema absent after up") } // Down the payments migration and assert a clean teardown. goose.SetBaseFS(migrations.Migrations()) defer goose.SetBaseFS(nil) if err := goose.SetDialect("postgres"); err != nil { t.Fatalf("set dialect: %v", err) } if err := goose.DownToContext(ctx, db, ".", 9); err != nil { t.Fatalf("down to 9: %v", err) } if schemaExists(ctx, t, db, "payments") { t.Error("payments schema survived the down migration") } var roleExists bool if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil { t.Fatalf("check role after down: %v", err) } if roleExists { t.Error("payments role survived the down migration") } // Re-apply and assert it comes back cleanly. if err := goose.UpContext(ctx, db, "."); err != nil { t.Fatalf("re-apply up: %v", err) } if !schemaExists(ctx, t, db, "payments") { t.Fatal("payments schema absent after re-apply") } } // schemaExists reports whether a schema of the given name is present. func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool { t.Helper() var exists bool if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil { t.Fatalf("check schema %s: %v", name, err) } return exists }