Two defects in the §9.3 quota path, both invisible to the hermetic suite: - Audit #4's TOCTOU was real and documented: QuotaCheck and ClaimServer were separate statements, so two concurrent claims by one user for two different ownerless servers both read count < max_servers and both won. The gate now lives inside ClaimServer, in the SAME transaction as the ownership write, under pg_advisory_xact_lock(hashtext(user_id)) — the aggregate read, the four-dimension re-check (shared with QuotaCheck via one helper so the two cannot drift), and the UPDATE are one serialized decision. The loser gets ErrQuotaExceeded, which both claim handlers map to the same 403 the sequential path gives; the server row is additionally taken FOR UPDATE so same-server races still resolve to exactly one winner. - The server PATCH path called UpdateServerResources(..., 0) for storage even though a resources patch cannot change storage. The cached columns are the ONLY input to the quota aggregate, so every resource patch silently dropped that server's storage contribution from its owner's cap. The handler now reads the current spec and passes storage through. Red-then-green: the new pgint test drives two real concurrent claims against max_servers=1 (before: both win; now: exactly one win + one gated 403, and the DB shows one owned row); the hermetic suite pins the 403 mapping and the storage-preserving cache write.
988 lines
37 KiB
Go
988 lines
37 KiB
Go
//go:build pgint
|
|
|
|
// Package pgint holds the Postgres-level contract tests for the business stores
|
|
// (api.PGRepo, submit.PGStore, build.PGStore). They exist because pure unit suites
|
|
// run against fakes that encode the CONTRACT — and PGRepo drifted behind that
|
|
// contract three times (attempt accounting, a missing JOIN, a missing FOR UPDATE)
|
|
// while every unit test stayed green.
|
|
//
|
|
// They run ONLY against a throwaway database whose name contains "pgint": the
|
|
// harness drops and recreates the public schema and replays the real embedded
|
|
// migrations, so the schema under test is exactly what `felis migrate up`
|
|
// produces. Point it at the test database, never at a live one:
|
|
//
|
|
// FELIS_TEST_PG_URL='postgres://felis:***@127.0.0.1:15432/felis_pgint?sslmode=disable' \
|
|
// go test -tags pgint ./internal/pgint/ -v
|
|
package pgint
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
"felis.lolicon.best/internal/build"
|
|
"felis.lolicon.best/internal/store"
|
|
"felis.lolicon.best/internal/submit"
|
|
)
|
|
|
|
var (
|
|
db *sql.DB
|
|
repo *api.PGRepo
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
dsn := os.Getenv("FELIS_TEST_PG_URL")
|
|
if dsn == "" {
|
|
fmt.Fprintln(os.Stderr, `pgint: FELIS_TEST_PG_URL must name a throwaway test database (its name must contain "pgint")`)
|
|
os.Exit(1)
|
|
}
|
|
// The harness drops the public schema; refuse anything that does not look
|
|
// like a dedicated test database, so a copy-paste of the production DSN
|
|
// cannot wipe a live install.
|
|
u, err := url.Parse(dsn)
|
|
if err != nil || !strings.Contains(strings.TrimPrefix(u.Path, "/"), "pgint") {
|
|
fmt.Fprintf(os.Stderr, "pgint: refusing %q: the database name must contain \"pgint\"\n", dsn)
|
|
os.Exit(1)
|
|
}
|
|
ctx := context.Background()
|
|
drv, err := store.Open(ctx, dsn)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pgint: open: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer drv.Close()
|
|
// Fresh schema + the real migrations: violations of the schema contract
|
|
// (bad enum, missing column) fail here, not mid-suite.
|
|
for _, stmt := range []string{"DROP SCHEMA IF EXISTS public CASCADE", "CREATE SCHEMA public"} {
|
|
if _, err := drv.DB().ExecContext(ctx, stmt); err != nil {
|
|
fmt.Fprintf(os.Stderr, "pgint: reset schema: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
ms, err := store.LoadMigrations()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pgint: load migrations: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if _, err := store.Up(ctx, drv, ms); err != nil {
|
|
fmt.Fprintf(os.Stderr, "pgint: migrate: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
db = drv.DB()
|
|
repo = api.NewPGRepo(db)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// suffix returns a short unique hex string for per-test identifiers, so the
|
|
// suites can share one schema without interfering.
|
|
func suffix(t *testing.T) string {
|
|
t.Helper()
|
|
var b [6]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// testUUID formats random bytes as a UUID string (the version nibble does not
|
|
// matter to Postgres's uuid type).
|
|
func testUUID(t *testing.T) string {
|
|
t.Helper()
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
func newUser(t *testing.T, role, prefix string) *api.UserView {
|
|
t.Helper()
|
|
u, err := repo.CreateUser(context.Background(),
|
|
api.CreateUserInput{Username: prefix + "-" + suffix(t), Role: role}, "pgint")
|
|
if err != nil {
|
|
t.Fatalf("CreateUser(%s): %v", role, err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func mustNow() time.Time { return time.Now().UTC() }
|
|
|
|
// ---- sessions -----------------------------------------------------------------
|
|
|
|
func TestSessionLifecycle(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "sess")
|
|
hash := "tok-" + suffix(t)
|
|
expires := mustNow().Add(time.Hour)
|
|
|
|
if err := repo.CreateSession(ctx, hash, u.ID, expires); err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
su, err := repo.SessionUser(ctx, hash, mustNow())
|
|
if err != nil {
|
|
t.Fatalf("SessionUser live: %v", err)
|
|
}
|
|
if su.ID != u.ID || su.Role != "user" || su.EmailVerified {
|
|
t.Fatalf("SessionedUser = %+v, want id=%s role=user verified=false", su, u.ID)
|
|
}
|
|
|
|
// Expiry is evaluated against the supplied clock, not wall time.
|
|
if _, err := repo.SessionUser(ctx, hash, expires.Add(time.Second)); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("expired session = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
// Revoke is idempotent and kills the session even before expiry.
|
|
if err := repo.RevokeSession(ctx, hash); err != nil {
|
|
t.Fatalf("RevokeSession: %v", err)
|
|
}
|
|
if err := repo.RevokeSession(ctx, hash); err != nil {
|
|
t.Fatalf("RevokeSession twice must be idempotent: %v", err)
|
|
}
|
|
if _, err := repo.SessionUser(ctx, hash, mustNow()); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("revoked session = %v, want ErrNotFound", err)
|
|
}
|
|
if _, err := repo.SessionUser(ctx, "no-such-"+suffix(t), mustNow()); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("unknown session = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// ---- email OTP: onboarding primitive ------------------------------------------
|
|
|
|
func TestOnboardingEmailOTPContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "otp")
|
|
purpose := "onboard_email"
|
|
addr := "otp-" + suffix(t) + "@example.net"
|
|
now := mustNow()
|
|
|
|
// No live code at all.
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h1", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("verify without code = %v, want ErrOTPInvalid", err)
|
|
}
|
|
|
|
if err := repo.CreateEmailOTP(ctx, "otp-"+suffix(t), u.ID, addr, "h-good", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP: %v", err)
|
|
}
|
|
// A mismatch charges one attempt and does NOT consume the code.
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-bad", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("wrong code = %v, want ErrOTPInvalid", err)
|
|
}
|
|
assertAttempts(t, u.ID, purpose, 1)
|
|
// The right code still redeems after a typo, and proves the address.
|
|
got, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-good", now)
|
|
if err != nil || got != addr {
|
|
t.Fatalf("verify = (%q, %v), want (%q, nil)", got, err, addr)
|
|
}
|
|
assertEmailProven(t, u.ID, addr, true)
|
|
// Replay: consumed codes stay dead.
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-good", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("replay = %v, want ErrOTPInvalid", err)
|
|
}
|
|
|
|
// A new code supersedes the previous live one (at most one outstanding).
|
|
if err := repo.CreateEmailOTP(ctx, "otp2-"+suffix(t), u.ID, addr, "h-new", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP (second): %v", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-old", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("superseded code = %v, want ErrOTPInvalid", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-new", now); err != nil {
|
|
t.Fatalf("newest code must redeem: %v", err)
|
|
}
|
|
|
|
// Expiry: a past expires_at is invalid, never a success.
|
|
if err := repo.CreateEmailOTP(ctx, "otp3-"+suffix(t), u.ID, addr, "h-exp", purpose, now.Add(-time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP (expired): %v", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-exp", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("expired code = %v, want ErrOTPInvalid", err)
|
|
}
|
|
|
|
// Lockout: otpMaxAttempts wrong guesses retire the code (correct hash or not).
|
|
if err := repo.CreateEmailOTP(ctx, "otp4-"+suffix(t), u.ID, addr, "h-lock", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP (lock): %v", err)
|
|
}
|
|
locked := false
|
|
for i := 0; i < 10 && !locked; i++ {
|
|
_, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-wrong", now)
|
|
switch {
|
|
case errors.Is(err, api.ErrOTPInvalid):
|
|
case errors.Is(err, api.ErrOTPLocked):
|
|
locked = true
|
|
default:
|
|
t.Fatalf("wrong guess %d = %v, want invalid/locked", i+1, err)
|
|
}
|
|
}
|
|
if !locked {
|
|
t.Fatal("attempt budget never exhausted after 10 wrong guesses")
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h-lock", now); !errors.Is(err, api.ErrOTPLocked) {
|
|
t.Fatalf("correct code after lock = %v, want ErrOTPLocked", err)
|
|
}
|
|
assertConsumed(t, u.ID, purpose, false)
|
|
}
|
|
|
|
// The verified-email uniqueness guard: two accounts cannot prove the same
|
|
// address (the login door resolves accounts BY verified email, so a duplicate
|
|
// would make identity ambiguous). This is the guard ConsumeLoginEmailOTP
|
|
// deliberately skips.
|
|
func TestOnboardingEmailOTPRejectsTakenEmail(t *testing.T) {
|
|
ctx := context.Background()
|
|
a, b := newUser(t, "user", "take-a"), newUser(t, "user", "take-b")
|
|
addr := "shared-" + suffix(t) + "@example.net"
|
|
now := mustNow()
|
|
purpose := "onboard_email"
|
|
|
|
if err := repo.CreateEmailOTP(ctx, "tka-"+suffix(t), a.ID, addr, "h-a", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP(a): %v", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, a.ID, purpose, "h-a", now); err != nil {
|
|
t.Fatalf("verify a: %v", err)
|
|
}
|
|
if err := repo.CreateEmailOTP(ctx, "tkb-"+suffix(t), b.ID, addr, "h-b", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP(b): %v", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, b.ID, purpose, "h-b", now); !errors.Is(err, api.ErrEmailTaken) {
|
|
t.Fatalf("second account proving a taken email = %v, want ErrEmailTaken", err)
|
|
}
|
|
// The address, not the code, was the problem: b's code stays live.
|
|
assertConsumed(t, b.ID, purpose, false)
|
|
// The invariant is also enforced by the database, not only the app guard: a
|
|
// direct write that bypasses VerifyEmailOTP still loses (uppercased to prove
|
|
// the index keys on lower(email)).
|
|
if _, err := db.ExecContext(ctx,
|
|
`UPDATE users SET email = $2, email_verified = true WHERE id = $1`, b.ID, strings.ToUpper(addr)); err == nil {
|
|
t.Fatal("a direct duplicate verified-email write succeeded; users_verified_email_unique is missing")
|
|
}
|
|
// And the refusal does not wedge b: its OWN address still verifies fine.
|
|
own := "own-" + suffix(t) + "@example.net"
|
|
if err := repo.CreateEmailOTP(ctx, "tkb2-"+suffix(t), b.ID, own, "h-b2", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP(b, own): %v", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, b.ID, purpose, "h-b2", now); err != nil {
|
|
t.Fatalf("b must still be able to prove its own address: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- email OTP: pre-session login primitive (regression: the #16 drift) --------
|
|
|
|
func TestConsumeLoginEmailOTPContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "login-otp")
|
|
purpose := "login_email"
|
|
addr := "login-" + suffix(t) + "@example.net"
|
|
now := mustNow()
|
|
|
|
if err := repo.CreateEmailOTP(ctx, "lotp-"+suffix(t), u.ID, addr, "h-good", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP: %v", err)
|
|
}
|
|
// Wrong guesses must ACCUMULATE (the drift left attempts at zero) and must
|
|
// not consume the code.
|
|
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-bad", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("wrong code = %v, want ErrOTPInvalid", err)
|
|
}
|
|
assertAttempts(t, u.ID, purpose, 1)
|
|
// Redemption succeeds and is single-use.
|
|
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-good", now); err != nil {
|
|
t.Fatalf("correct code = %v, want nil", err)
|
|
}
|
|
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-good", now); !errors.Is(err, api.ErrOTPInvalid) {
|
|
t.Fatalf("replay = %v, want ErrOTPInvalid", err)
|
|
}
|
|
// The login primitive writes NO identity state: the user's email stays empty.
|
|
assertEmailProven(t, u.ID, "", false)
|
|
|
|
// Lockout parity with the onboarding primitive.
|
|
if err := repo.CreateEmailOTP(ctx, "lotp2-"+suffix(t), u.ID, addr, "h-lock", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP (lock): %v", err)
|
|
}
|
|
locked := false
|
|
for i := 0; i < 10 && !locked; i++ {
|
|
switch err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", now); {
|
|
case errors.Is(err, api.ErrOTPInvalid):
|
|
case errors.Is(err, api.ErrOTPLocked):
|
|
locked = true
|
|
default:
|
|
t.Fatalf("wrong guess %d = %v, want invalid/locked", i+1, err)
|
|
}
|
|
}
|
|
if !locked {
|
|
t.Fatal("attempt budget never exhausted after 10 wrong guesses")
|
|
}
|
|
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-lock", now); !errors.Is(err, api.ErrOTPLocked) {
|
|
t.Fatalf("correct code after lock = %v, want ErrOTPLocked", err)
|
|
}
|
|
}
|
|
|
|
// ---- user admin (spec §7) -------------------------------------------------------
|
|
|
|
// The claim gate must hold under concurrency (audit #4): two simultaneous claims
|
|
// by one user for two different ownerless servers must not both pass a
|
|
// max_servers=1 cap. ClaimServer now owns the gate (advisory lock + four-dimension
|
|
// re-check in the same transaction as the ownership write), so this drives real
|
|
// goroutines against real Postgres.
|
|
func TestClaimServerQuotaAtomicGate(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "quota")
|
|
one := 1
|
|
if _, err := repo.SetQuotas(ctx, u.ID, api.QuotaInput{MaxServers: &one}, "pgint"); err != nil {
|
|
t.Fatalf("SetQuotas: %v", err)
|
|
}
|
|
seed := func(name string) {
|
|
t.Helper()
|
|
if _, err := db.ExecContext(ctx,
|
|
`INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, 100, 128, 1)`,
|
|
name); err != nil {
|
|
t.Fatalf("seed server %s: %v", name, err)
|
|
}
|
|
}
|
|
s1, s2 := "qa-"+suffix(t), "qb-"+suffix(t)
|
|
seed(s1)
|
|
seed(s2)
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]error, 2)
|
|
claimed := make([]bool, 2)
|
|
for i, name := range []string{s1, s2} {
|
|
wg.Add(1)
|
|
go func(i int, name string) {
|
|
defer wg.Done()
|
|
claimed[i], results[i] = repo.ClaimServer(ctx, name, u.ID)
|
|
}(i, name)
|
|
}
|
|
wg.Wait()
|
|
|
|
var wins, gated, other int
|
|
for i := range results {
|
|
switch {
|
|
case results[i] == nil && claimed[i]:
|
|
wins++
|
|
case errors.Is(results[i], api.ErrQuotaExceeded):
|
|
gated++
|
|
default:
|
|
other++
|
|
t.Logf("unexpected outcome %d: claimed=%v err=%v", i, claimed[i], results[i])
|
|
}
|
|
}
|
|
if wins != 1 || gated != 1 || other != 0 {
|
|
t.Fatalf("concurrent claims: wins=%d gated=%d other=%d, want 1/1/0", wins, gated, other)
|
|
}
|
|
var owned int
|
|
if err := db.QueryRow(`SELECT count(*) FROM servers WHERE owner_id = $1 AND deleted_at IS NULL`, u.ID).Scan(&owned); err != nil {
|
|
t.Fatalf("count owned: %v", err)
|
|
}
|
|
if owned != 1 {
|
|
t.Fatalf("owned servers = %d, want exactly 1 (no over-provision)", owned)
|
|
}
|
|
|
|
// Sequential, the gate answers identically: a third claim meets the same 403.
|
|
s3 := "qc-" + suffix(t)
|
|
seed(s3)
|
|
if _, err := repo.ClaimServer(ctx, s3, u.ID); !errors.Is(err, api.ErrQuotaExceeded) {
|
|
t.Fatalf("third claim = %v, want ErrQuotaExceeded", err)
|
|
}
|
|
|
|
// No quota row → unlimited: a fresh user claims both remaining ownerless rows.
|
|
otherUser := newUser(t, "user", "quota-free")
|
|
var free1 string
|
|
if err := db.QueryRow(
|
|
`SELECT name FROM servers WHERE name IN ($1, $2) AND owner_id IS NULL ORDER BY name LIMIT 1`,
|
|
s1, s2).Scan(&free1); err != nil {
|
|
t.Fatalf("find the race's unclaimed row: %v", err)
|
|
}
|
|
for _, name := range []string{free1, s3} {
|
|
if ok, err := repo.ClaimServer(ctx, name, otherUser.ID); err != nil || !ok {
|
|
t.Fatalf("quota-free claim %s = (%v, %v), want (true, nil)", name, ok, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An admin email edit must not carry a verification over to an address nobody
|
|
// proved: the verified flag is exactly what the pre-session login resolves on
|
|
// (UserByEmail), and only VerifyEmailOTP may assert it — the same rationale as
|
|
// SetUserEmail. A no-op edit that passes the same value keeps the proof.
|
|
func TestUserAdminEmailEditClearsVerification(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "admin-edit")
|
|
purpose := "onboard_email"
|
|
addr := "edit-" + suffix(t) + "@example.net"
|
|
now := mustNow()
|
|
|
|
if err := repo.CreateEmailOTP(ctx, "ae-"+suffix(t), u.ID, addr, "h", purpose, now.Add(5*time.Minute)); err != nil {
|
|
t.Fatalf("CreateEmailOTP: %v", err)
|
|
}
|
|
if _, err := repo.VerifyEmailOTP(ctx, u.ID, purpose, "h", now); err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
assertEmailProven(t, u.ID, addr, true)
|
|
|
|
same := addr
|
|
if _, err := repo.UpdateUser(ctx, u.ID, api.UpdateUserInput{Email: &same}, "pgint"); err != nil {
|
|
t.Fatalf("UpdateUser (same email): %v", err)
|
|
}
|
|
assertEmailProven(t, u.ID, addr, true)
|
|
|
|
next := "edit2-" + suffix(t) + "@example.net"
|
|
if _, err := repo.UpdateUser(ctx, u.ID, api.UpdateUserInput{Email: &next}, "pgint"); err != nil {
|
|
t.Fatalf("UpdateUser (new email): %v", err)
|
|
}
|
|
assertEmailProven(t, u.ID, next, false)
|
|
}
|
|
|
|
// The owner tier the panel gates on must actually be WRITTEN: until this
|
|
// contract had a test, every provisioning path wrote 'admin', so the whole
|
|
// owner surface (user administration) was unreachable in a fresh install.
|
|
func TestOwnerProvisioningWritesOwnerRole(t *testing.T) {
|
|
ctx := context.Background()
|
|
name := "owner-" + suffix(t)
|
|
id := "usr-" + suffix(t)
|
|
if err := repo.UpsertOwner(ctx, id, name, "owner-"+suffix(t)+"@example.net"); err != nil {
|
|
t.Fatalf("UpsertOwner: %v", err)
|
|
}
|
|
if role := userRole(t, id); role != "owner" {
|
|
t.Fatalf("UpsertOwner role = %q, want owner", role)
|
|
}
|
|
// Re-running the break-glass path resets (email) and PROMOTES (role) in
|
|
// place — the documented upgrade for a pre-0011 'admin' Owner row.
|
|
if err := repo.UpsertOwner(ctx, "usr-other-"+suffix(t), name, "[email protected]"); err != nil {
|
|
t.Fatalf("UpsertOwner (reset): %v", err)
|
|
}
|
|
var gotID, email, role string
|
|
if err := db.QueryRow(`SELECT id, COALESCE(email, ''), role::text FROM users WHERE username = $1`, name).
|
|
Scan(&gotID, &email, &role); err != nil {
|
|
t.Fatalf("read owner row: %v", err)
|
|
}
|
|
if gotID != id || email != "[email protected]" || role != "owner" {
|
|
t.Fatalf("reset row = (%s, %s, %s), want id preserved + email reset + owner", gotID, email, role)
|
|
}
|
|
if ok, err := repo.AdminExists(ctx); err != nil || !ok {
|
|
t.Fatalf("AdminExists = (%v, %v), want true (the owner counts as staff)", ok, err)
|
|
}
|
|
// Operators stay plain admins: the owner tier stays singular.
|
|
opID := "usr-op-" + suffix(t)
|
|
if err := repo.InsertOperator(ctx, opID, "op-"+suffix(t), ""); err != nil {
|
|
t.Fatalf("InsertOperator: %v", err)
|
|
}
|
|
if role := userRole(t, opID); role != "admin" {
|
|
t.Fatalf("InsertOperator role = %q, want admin", role)
|
|
}
|
|
}
|
|
|
|
// The setup wizard's MC-bind path establishes THE Owner, so it writes the same
|
|
// role as break-glass rather than a plain admin.
|
|
func TestCompleteOwnerSetupWritesOwnerRole(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := mustNow()
|
|
mc := testUUID(t)
|
|
code := "osc-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode: %v", err)
|
|
}
|
|
newID := "usr-setup-" + suffix(t)
|
|
userID, gotUUID, src, err := repo.CompleteOwnerSetup(ctx, newID, code, now,
|
|
"tok-"+suffix(t), now.Add(time.Hour))
|
|
if err != nil || userID != newID || gotUUID != mc || src != "mojang" {
|
|
t.Fatalf("CompleteOwnerSetup = (%s, %s, %s, %v), want (%s, %s, mojang, nil)",
|
|
userID, gotUUID, src, err, newID, mc)
|
|
}
|
|
if role := userRole(t, newID); role != "owner" {
|
|
t.Fatalf("CompleteOwnerSetup role = %q, want owner", role)
|
|
}
|
|
}
|
|
|
|
// IsProtectedAdminLink is one of the staff predicates the owner role must flow
|
|
// through: the Owner logging in via the third-party Yggdrasil must never be
|
|
// barred by a Mojang-priority reclaim, exactly like an Operator.
|
|
func TestIsProtectedAdminLinkStaffRoles(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := mustNow()
|
|
link := func(userID, source string) string {
|
|
t.Helper()
|
|
mc := testUUID(t)
|
|
code := "pro-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, code, mc, source, now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode(%s): %v", source, err)
|
|
}
|
|
if _, _, err := repo.VerifyLinkCode(ctx, userID, code, now); err != nil {
|
|
t.Fatalf("VerifyLinkCode(%s): %v", source, err)
|
|
}
|
|
return mc
|
|
}
|
|
|
|
ownerID := "usr-" + suffix(t)
|
|
if err := repo.UpsertOwner(ctx, ownerID, "prot-owner-"+suffix(t), ""); err != nil {
|
|
t.Fatalf("UpsertOwner: %v", err)
|
|
}
|
|
admin := newUser(t, "admin", "prot-admin")
|
|
player := newUser(t, "user", "prot-player")
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
mc string
|
|
want bool
|
|
}{
|
|
{"owner via thirdparty", link(ownerID, "thirdparty"), true},
|
|
{"admin via thirdparty", link(admin.ID, "thirdparty"), true},
|
|
{"player via thirdparty", link(player.ID, "thirdparty"), false},
|
|
{"admin via mojang", link(admin.ID, "mojang"), false},
|
|
} {
|
|
got, err := repo.IsProtectedAdminLink(ctx, tc.mc)
|
|
if err != nil || got != tc.want {
|
|
t.Fatalf("%s = (%v, %v), want %v", tc.name, got, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- op.console staff login state machine --------------------------------------
|
|
|
|
func TestOpLoginStateMachine(t *testing.T) {
|
|
ctx := context.Background()
|
|
staff := newUser(t, "admin", "staff")
|
|
approver := newUser(t, "admin", "approver")
|
|
now := mustNow()
|
|
id := "opl-" + suffix(t)
|
|
|
|
if err := repo.CreateOpLoginRequest(ctx, id, staff.ID, "[email protected]", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateOpLoginRequest: %v", err)
|
|
}
|
|
req, err := repo.OpLoginRequestByID(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("OpLoginRequestByID: %v", err)
|
|
}
|
|
if req.Status != "pending" || req.Consumed {
|
|
t.Fatalf("fresh request = %+v, want pending+unconsumed", req)
|
|
}
|
|
if req.Username != "" {
|
|
t.Errorf("ByID must not join a username, got %q", req.Username)
|
|
}
|
|
|
|
// The pending list is what the in-game admin sees: it must name the staff
|
|
// user and carry a real created_at (the #17 drift left both empty).
|
|
pending, err := repo.ListPendingOpLogins(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("ListPendingOpLogins: %v", err)
|
|
}
|
|
found := false
|
|
for _, p := range pending {
|
|
if p.ID != id {
|
|
continue
|
|
}
|
|
found = true
|
|
if p.Username != staff.Username {
|
|
t.Errorf("pending username = %q, want %q", p.Username, staff.Username)
|
|
}
|
|
if p.CreatedAt.IsZero() {
|
|
t.Error("pending created_at is zero")
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("fresh pending request missing from the list")
|
|
}
|
|
|
|
// Approve -> consume -> single use; second approve/consume are ErrNotFound.
|
|
if err := repo.ApproveOpLogin(ctx, id, approver.ID, now); err != nil {
|
|
t.Fatalf("ApproveOpLogin: %v", err)
|
|
}
|
|
if err := repo.ApproveOpLogin(ctx, id, approver.ID, now); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("double approve = %v, want ErrNotFound", err)
|
|
}
|
|
if err := repo.ConsumeOpLoginRequest(ctx, id, now); err != nil {
|
|
t.Fatalf("ConsumeOpLoginRequest: %v", err)
|
|
}
|
|
if err := repo.ConsumeOpLoginRequest(ctx, id, now); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("double consume = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
// An expired request is dead on every path.
|
|
oldID := "opl-old-" + suffix(t)
|
|
if err := repo.CreateOpLoginRequest(ctx, oldID, staff.ID, "[email protected]", now.Add(-time.Minute)); err != nil {
|
|
t.Fatalf("CreateOpLoginRequest (expired): %v", err)
|
|
}
|
|
pending, err = repo.ListPendingOpLogins(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("ListPendingOpLogins (after expiry): %v", err)
|
|
}
|
|
for _, p := range pending {
|
|
if p.ID == oldID {
|
|
t.Fatal("expired request must not be listed as actionable")
|
|
}
|
|
}
|
|
if err := repo.ApproveOpLogin(ctx, oldID, approver.ID, now); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("approve expired = %v, want ErrNotFound", err)
|
|
}
|
|
if err := repo.ConsumeOpLoginRequest(ctx, oldID, now); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("consume expired = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// ---- account links -------------------------------------------------------------
|
|
|
|
func TestVerifyLinkCodeContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
u1, u2 := newUser(t, "user", "link-a"), newUser(t, "user", "link-b")
|
|
mc := testUUID(t)
|
|
now := mustNow()
|
|
|
|
code := "lc-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode: %v", err)
|
|
}
|
|
gotUUID, src, err := repo.VerifyLinkCode(ctx, u1.ID, code, now)
|
|
if err != nil || gotUUID != mc || src != "mojang" {
|
|
t.Fatalf("VerifyLinkCode = (%s, %s, %v), want (%s, mojang, nil)", gotUUID, src, err, mc)
|
|
}
|
|
if ok, err := repo.IsLinked(ctx, u1.ID); err != nil || !ok {
|
|
t.Fatalf("IsLinked after verify = (%v, %v), want (true, nil)", ok, err)
|
|
}
|
|
// Single use.
|
|
if _, _, err := repo.VerifyLinkCode(ctx, u1.ID, code, now); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("replay = %v, want ErrLinkCodeInvalid", err)
|
|
}
|
|
|
|
// A uuid already linked to ANOTHER user is a conflict, not a silent takeover.
|
|
code2 := "lc2-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, code2, mc, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (2): %v", err)
|
|
}
|
|
if _, _, err := repo.VerifyLinkCode(ctx, u2.ID, code2, now); !errors.Is(err, api.ErrConflict) {
|
|
t.Fatalf("cross-user link = %v, want ErrConflict", err)
|
|
}
|
|
|
|
// Re-verifying the same (user, uuid) is idempotent and refreshes authSource.
|
|
code3 := "lc3-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, code3, mc, "thirdparty", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (3): %v", err)
|
|
}
|
|
if _, src, err := repo.VerifyLinkCode(ctx, u1.ID, code3, now); err != nil || src != "thirdparty" {
|
|
t.Fatalf("re-verify = (%s, %v), want (thirdparty, nil)", src, err)
|
|
}
|
|
assertLinkAuthSource(t, u1.ID, mc, "thirdparty")
|
|
|
|
// Expired code: invalid, never usable.
|
|
expired := "lc4-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, expired, testUUID(t), "mojang", now.Add(-time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (expired): %v", err)
|
|
}
|
|
if _, _, err := repo.VerifyLinkCode(ctx, u2.ID, expired, now); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("expired code = %v, want ErrLinkCodeInvalid", err)
|
|
}
|
|
}
|
|
|
|
// RedeemPlayerBindCode is the account-less console bootstrap (regression: the #18
|
|
// concurrency drift). Its documented outcomes are exercised here, including the
|
|
// one that must NOT consume the code.
|
|
func TestRedeemPlayerBindCodeContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := mustNow()
|
|
|
|
// New uuid: creates a user + link, consumes the code.
|
|
mcNew := testUUID(t)
|
|
codeNew := "bc-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, codeNew, mcNew, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode: %v", err)
|
|
}
|
|
newID := "player-" + suffix(t)
|
|
userID, gotUUID, src, err := repo.RedeemPlayerBindCode(ctx, newID, codeNew, now)
|
|
if err != nil || userID != newID || gotUUID != mcNew || src != "mojang" {
|
|
t.Fatalf("first redeem = (%s, %s, %s, %v), want (%s, %s, mojang, nil)", userID, gotUUID, src, err, newID, mcNew)
|
|
}
|
|
if ok, _ := repo.IsLinked(ctx, newID); !ok {
|
|
t.Fatal("redeem must write the account link")
|
|
}
|
|
if _, _, _, err := repo.RedeemPlayerBindCode(ctx, "player-x-"+suffix(t), codeNew, now); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("consumed code = %v, want ErrLinkCodeInvalid", err)
|
|
}
|
|
|
|
// Same uuid again: returns the EXISTING player, even when asked for a new id.
|
|
codeAgain := "bc2-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, codeAgain, mcNew, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (again): %v", err)
|
|
}
|
|
if userID, _, _, err := repo.RedeemPlayerBindCode(ctx, "player-y-"+suffix(t), codeAgain, now); err != nil || userID != newID {
|
|
t.Fatalf("second redeem = (%s, %v), want the same user %s", userID, err, newID)
|
|
}
|
|
|
|
// Staff uuid: forbidden, and the code is deliberately NOT consumed.
|
|
staff := newUser(t, "admin", "bound-staff")
|
|
mcStaff := testUUID(t)
|
|
staffCode := "bc3-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, staffCode, mcStaff, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (staff): %v", err)
|
|
}
|
|
if _, _, err := repo.VerifyLinkCode(ctx, staff.ID, staffCode, now); err != nil {
|
|
t.Fatalf("link staff uuid: %v", err)
|
|
}
|
|
staffRedeem := "bc4-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, staffRedeem, mcStaff, "mojang", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (staff redeem): %v", err)
|
|
}
|
|
if _, _, _, err := repo.RedeemPlayerBindCode(ctx, "player-z-"+suffix(t), staffRedeem, now); !errors.Is(err, api.ErrPlayerBindForbidden) {
|
|
t.Fatalf("staff redeem = %v, want ErrPlayerBindForbidden", err)
|
|
}
|
|
assertLinkCodeUnconsumed(t, staffRedeem)
|
|
|
|
// Expired: invalid, unconsumed.
|
|
expCode := "bc5-" + suffix(t)
|
|
if err := repo.CreateLinkCode(ctx, expCode, testUUID(t), "mojang", now.Add(-time.Minute)); err != nil {
|
|
t.Fatalf("CreateLinkCode (expired): %v", err)
|
|
}
|
|
if _, _, _, err := repo.RedeemPlayerBindCode(ctx, "player-w-"+suffix(t), expCode, now); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("expired redeem = %v, want ErrLinkCodeInvalid", err)
|
|
}
|
|
assertLinkCodeUnconsumed(t, expCode)
|
|
}
|
|
|
|
// ---- submissions ---------------------------------------------------------------
|
|
|
|
func TestSubmitStoreContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "sub")
|
|
s := submit.NewPGStore(db)
|
|
now := mustNow()
|
|
id := "sub-" + suffix(t)
|
|
|
|
sub := &submit.Submission{
|
|
ID: id, SubmittedBy: u.ID, DisplayName: "pgint pack",
|
|
ContextRef: "s3://bucket/" + id + "/context.tar.gz",
|
|
Status: submit.StatusPendingReview, CreatedAt: now,
|
|
}
|
|
if err := s.CreateSubmission(ctx, sub); err != nil {
|
|
t.Fatalf("CreateSubmission: %v", err)
|
|
}
|
|
got, err := s.GetSubmission(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetSubmission: %v", err)
|
|
}
|
|
if got.SubmittedBy != u.ID || got.DisplayName != "pgint pack" || got.Status != submit.StatusPendingReview {
|
|
t.Fatalf("round-trip = %+v", got)
|
|
}
|
|
if _, err := s.GetSubmission(ctx, "missing-"+suffix(t)); !errors.Is(err, submit.ErrNotFound) {
|
|
t.Fatalf("missing submission = %v, want ErrNotFound", err)
|
|
}
|
|
byUser, err := s.ListSubmissionsBy(ctx, u.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSubmissionsBy: %v", err)
|
|
}
|
|
if !containsSubmission(byUser, id) {
|
|
t.Fatal("ListSubmissionsBy must return the caller's submission")
|
|
}
|
|
if all, err := s.ListSubmissions(ctx); err != nil || !containsSubmission(all, id) {
|
|
t.Fatalf("ListSubmissions: (%v, %v), want the submission present", all, err)
|
|
}
|
|
|
|
// The approval CAS: exactly one winner, and only from pending_review.
|
|
ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry.felis.svc:5000/user-uploads/"+id+":latest", now)
|
|
if err != nil || !ok {
|
|
t.Fatalf("ApproveSubmission = (%v, %v), want (true, nil)", ok, err)
|
|
}
|
|
if ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry/x:1", now); err != nil || ok {
|
|
t.Fatalf("second approve = (%v, %v), want (false, nil)", ok, err)
|
|
}
|
|
if ok, err := s.RejectSubmission(ctx, id, "[email protected]", "no", now); err != nil || ok {
|
|
t.Fatalf("reject after approve = (%v, %v), want (false, nil)", ok, err)
|
|
}
|
|
got, _ = s.GetSubmission(ctx, id)
|
|
if got.Status != submit.StatusApproved || got.ImageRef == "" || got.ReviewedBy != "[email protected]" || got.ReviewedAt == nil {
|
|
t.Fatalf("approved row = %+v", got)
|
|
}
|
|
if err := s.LinkBuild(ctx, id, "bld-"+suffix(t)); err != nil {
|
|
t.Fatalf("LinkBuild: %v", err)
|
|
}
|
|
if got, _ = s.GetSubmission(ctx, id); got.BuildID == "" {
|
|
t.Fatal("LinkBuild must persist build_id")
|
|
}
|
|
}
|
|
|
|
// ---- builds --------------------------------------------------------------------
|
|
|
|
func TestBuildStoreContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := build.NewPGStore(db)
|
|
now := mustNow()
|
|
|
|
done := "bld-done-" + suffix(t)
|
|
if err := s.CreateBuild(ctx, &build.Build{ID: done, ImageRef: "registry.felis.svc:5000/user-uploads/" + done + ":latest",
|
|
Status: build.StatusPending, RequestedBy: "pgint", Dockerfile: "FROM scratch\n", ContextRef: "http://api/x"}); err != nil {
|
|
t.Fatalf("CreateBuild: %v", err)
|
|
}
|
|
got, err := s.GetBuild(ctx, done)
|
|
if err != nil {
|
|
t.Fatalf("GetBuild: %v", err)
|
|
}
|
|
if got.Status != build.StatusPending || got.JobName != "" {
|
|
t.Fatalf("fresh build = %+v", got)
|
|
}
|
|
if err := s.SetBuildJob(ctx, done, "job-"+suffix(t)); err != nil {
|
|
t.Fatalf("SetBuildJob: %v", err)
|
|
}
|
|
got, _ = s.GetBuild(ctx, done)
|
|
if got.JobName == "" || got.Status != build.StatusBuilding {
|
|
t.Fatalf("SetBuildJob must record the job and advance to building, got %+v", got)
|
|
}
|
|
if err := s.FinishBuild(ctx, done, build.StatusSucceeded, "", now); err != nil {
|
|
t.Fatalf("FinishBuild: %v", err)
|
|
}
|
|
got, _ = s.GetBuild(ctx, done)
|
|
if got.Status != build.StatusSucceeded || got.FinishedAt == nil {
|
|
t.Fatalf("finished build = %+v", got)
|
|
}
|
|
if _, err := s.GetBuild(ctx, "missing-"+suffix(t)); !errors.Is(err, build.ErrNotFound) {
|
|
t.Fatalf("missing build = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
running := "bld-run-" + suffix(t)
|
|
if err := s.CreateBuild(ctx, &build.Build{ID: running, ImageRef: "registry.felis.svc:5000/user-uploads/" + running + ":latest",
|
|
Status: build.StatusPending, RequestedBy: "pgint"}); err != nil {
|
|
t.Fatalf("CreateBuild (running): %v", err)
|
|
}
|
|
unfinished, err := s.ListUnfinishedBuilds(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListUnfinishedBuilds: %v", err)
|
|
}
|
|
var sawRunning, sawDone bool
|
|
for _, b := range unfinished {
|
|
sawRunning = sawRunning || b.ID == running
|
|
sawDone = sawDone || b.ID == done
|
|
}
|
|
if !sawRunning || sawDone {
|
|
t.Fatalf("ListUnfinishedBuilds: running=%v finished=%v, want true/false", sawRunning, sawDone)
|
|
}
|
|
|
|
// Image admission round trip (the scan-gate success path + external admit).
|
|
builtRef := "registry.felis.svc:5000/user-uploads/" + running + ":latest"
|
|
if err := s.AdmitBuiltImage(ctx, build.Image{ImageRef: builtRef, BuildID: running, AddedBy: "felis-api"}); err != nil {
|
|
t.Fatalf("AdmitBuiltImage: %v", err)
|
|
}
|
|
images, err := s.ListImages(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListImages: %v", err)
|
|
}
|
|
if img, ok := findImage(images, builtRef); !ok || !img.Enabled || img.Source != "built" {
|
|
t.Fatalf("admitted image = %+v (found=%v), want enabled built", img, ok)
|
|
}
|
|
if err := s.RemoveImage(ctx, builtRef); err != nil {
|
|
t.Fatalf("RemoveImage: %v", err)
|
|
}
|
|
if images, _ = s.ListImages(ctx); hasImage(images, builtRef) {
|
|
t.Fatal("RemoveImage must drop the row")
|
|
}
|
|
if err := s.RemoveImage(ctx, builtRef); !errors.Is(err, build.ErrNotFound) {
|
|
t.Fatalf("remove twice = %v, want ErrNotFound", err)
|
|
}
|
|
extRef := "registry.felis.svc:5000/mirror/ext:1"
|
|
if err := s.AddExternalImage(ctx, build.Image{ImageRef: extRef, AddedBy: "[email protected]"}); err != nil {
|
|
t.Fatalf("AddExternalImage: %v", err)
|
|
}
|
|
if images, _ = s.ListImages(ctx); !hasImage(images, extRef) {
|
|
t.Fatal("AddExternalImage must record the image")
|
|
}
|
|
}
|
|
|
|
// ---- helpers -------------------------------------------------------------------
|
|
|
|
func assertAttempts(t *testing.T, userID, purpose string, want int) {
|
|
t.Helper()
|
|
var got int
|
|
if err := db.QueryRow(`SELECT attempts FROM email_otps WHERE user_id=$1 AND purpose=$2 AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1`,
|
|
userID, purpose).Scan(&got); err != nil {
|
|
t.Fatalf("read attempts: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("attempts = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func assertConsumed(t *testing.T, userID, purpose string, want bool) {
|
|
t.Helper()
|
|
var consumed bool
|
|
if err := db.QueryRow(`SELECT consumed_at IS NOT NULL FROM email_otps WHERE user_id=$1 AND purpose=$2 ORDER BY created_at DESC LIMIT 1`,
|
|
userID, purpose).Scan(&consumed); err != nil {
|
|
t.Fatalf("read consumed: %v", err)
|
|
}
|
|
if consumed != want {
|
|
t.Fatalf("consumed = %v, want %v", consumed, want)
|
|
}
|
|
}
|
|
|
|
func assertEmailProven(t *testing.T, userID, wantEmail string, wantVerified bool) {
|
|
t.Helper()
|
|
var email string
|
|
var verified bool
|
|
if err := db.QueryRow(`SELECT COALESCE(email,''), COALESCE(email_verified,false) FROM users WHERE id=$1`, userID).
|
|
Scan(&email, &verified); err != nil {
|
|
t.Fatalf("read user email: %v", err)
|
|
}
|
|
if email != wantEmail || verified != wantVerified {
|
|
t.Fatalf("user email = (%q, %v), want (%q, %v)", email, verified, wantEmail, wantVerified)
|
|
}
|
|
}
|
|
|
|
func userRole(t *testing.T, userID string) string {
|
|
t.Helper()
|
|
var role string
|
|
if err := db.QueryRow(`SELECT role::text FROM users WHERE id = $1`, userID).Scan(&role); err != nil {
|
|
t.Fatalf("read user role: %v", err)
|
|
}
|
|
return role
|
|
}
|
|
|
|
func assertLinkAuthSource(t *testing.T, userID, mcUUID, want string) {
|
|
t.Helper()
|
|
var got string
|
|
if err := db.QueryRow(`SELECT auth_source::text FROM account_links WHERE user_id=$1 AND mc_uuid=$2`,
|
|
userID, mcUUID).Scan(&got); err != nil {
|
|
t.Fatalf("read link auth_source: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("auth_source = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// assertLinkCodeUnconsumed fails when the code row is gone. Consumption is a
|
|
// DELETE of the row (there is no consumed flag on account_link_codes), so mere
|
|
// row presence IS the "still redeemable" observable — the staff-redeem path
|
|
// must leave a pending code untouched.
|
|
func assertLinkCodeUnconsumed(t *testing.T, code string) {
|
|
t.Helper()
|
|
var exists bool
|
|
if err := db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account_link_codes WHERE code=$1)`, code).Scan(&exists); err != nil {
|
|
t.Fatalf("read link code: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("link code row is gone but the contract says it must survive")
|
|
}
|
|
}
|
|
|
|
func containsSubmission(subs []submit.Submission, id string) bool {
|
|
for _, s := range subs {
|
|
if s.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func findImage(images []build.Image, ref string) (build.Image, bool) {
|
|
for _, img := range images {
|
|
if img.ImageRef == ref {
|
|
return img, true
|
|
}
|
|
}
|
|
return build.Image{}, false
|
|
}
|
|
|
|
func hasImage(images []build.Image, ref string) bool {
|
|
_, ok := findImage(images, ref)
|
|
return ok
|
|
}
|