519 lines
19 KiB
Go
519 lines
19 KiB
Go
//go:build pgint
|
|
|
|
package pgint
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
)
|
|
|
|
// ---- account migration (migration 0015) -------------------------------------------
|
|
|
|
func seedOwnedServer(t *testing.T, name, ownerID string, deleted bool) {
|
|
t.Helper()
|
|
var deletedAt any
|
|
if deleted {
|
|
deletedAt = mustNow()
|
|
}
|
|
mustExec(t, `INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb, owner_id, deleted_at)
|
|
VALUES ($1, 100, 128, 1, $2, $3)`, name, ownerID, deletedAt)
|
|
}
|
|
|
|
func serverOwner(t *testing.T, name string) string {
|
|
t.Helper()
|
|
var owner string
|
|
if err := db.QueryRow(`SELECT COALESCE(owner_id, '') FROM servers WHERE name = $1`, name).Scan(&owner); err != nil {
|
|
t.Fatalf("owner of %s: %v", name, err)
|
|
}
|
|
return owner
|
|
}
|
|
|
|
func liveMigrations(t *testing.T, sourceID string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.QueryRow(`SELECT count(*) FROM account_migrations WHERE source_user_id = $1 AND state <> 'redeemed'`,
|
|
sourceID).Scan(&n); err != nil {
|
|
t.Fatalf("count live migrations: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// startToCode drives a source from nothing to an issued code for target.
|
|
func startToCode(t *testing.T, sourceID, targetID, codeHash string, now, expiresAt time.Time) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if err := repo.StartMigration(ctx, "mig-"+suffix(t), sourceID, now); err != nil {
|
|
t.Fatalf("StartMigration: %v", err)
|
|
}
|
|
if err := repo.ConfirmMigration(ctx, sourceID, "passkey", now); err != nil {
|
|
t.Fatalf("ConfirmMigration: %v", err)
|
|
}
|
|
if err := repo.IssueMigrationCode(ctx, sourceID, targetID, codeHash, expiresAt); err != nil {
|
|
t.Fatalf("IssueMigrationCode: %v", err)
|
|
}
|
|
}
|
|
|
|
// Each step advances from exactly one state, a restart kills the outstanding code,
|
|
// and the redeem moves the source's live servers to the named target, retires the
|
|
// source and ends its sessions, in one go and once.
|
|
func TestMigrationStateMachine(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := newUser(t, "user", "mig-src")
|
|
dst := newUser(t, "user", "mig-dst")
|
|
bystander := newUser(t, "user", "mig-by")
|
|
sfx := suffix(t)
|
|
liveA, liveB, gone, theirs := "ma-"+sfx, "mb-"+sfx, "mgone-"+sfx, "mtheirs-"+sfx
|
|
seedOwnedServer(t, liveA, src.ID, false)
|
|
seedOwnedServer(t, liveB, src.ID, false)
|
|
seedOwnedServer(t, gone, src.ID, true)
|
|
seedOwnedServer(t, theirs, bystander.ID, false)
|
|
t0 := mustNow().Truncate(time.Second)
|
|
srcSession := newSession(t, src.ID, "mig-src", t0.Add(time.Hour))
|
|
dstSession := newSession(t, dst.ID, "mig-dst", t0.Add(time.Hour))
|
|
|
|
if _, err := repo.MigrationForSource(ctx, src.ID); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("status before start = %v, want ErrNotFound", err)
|
|
}
|
|
if err := repo.ConfirmMigration(ctx, src.ID, "passkey", t0); !errors.Is(err, api.ErrConflict) {
|
|
t.Fatalf("confirm before start = %v, want ErrConflict", err)
|
|
}
|
|
if err := repo.StartMigration(ctx, "mig1-"+sfx, src.ID, t0); err != nil {
|
|
t.Fatalf("StartMigration: %v", err)
|
|
}
|
|
m, err := repo.MigrationForSource(ctx, src.ID)
|
|
if err != nil || m.ID != "mig1-"+sfx || m.State != "initiated" || m.TargetUserID != "" || m.ConfirmedAt != nil {
|
|
t.Fatalf("after start = %+v, %v; want mig1 initiated, no target, unconfirmed", m, err)
|
|
}
|
|
if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "h-skip", t0.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) {
|
|
t.Fatalf("issue before confirm = %v, want ErrConflict", err)
|
|
}
|
|
if err := repo.ConfirmMigration(ctx, src.ID, "email_otp", t0); err != nil {
|
|
t.Fatalf("ConfirmMigration: %v", err)
|
|
}
|
|
if err := repo.ConfirmMigration(ctx, src.ID, "passkey", t0.Add(time.Minute)); !errors.Is(err, api.ErrConflict) {
|
|
t.Fatalf("second confirm = %v, want ErrConflict", err)
|
|
}
|
|
m, err = repo.MigrationForSource(ctx, src.ID)
|
|
if err != nil || m.State != "confirmed" || m.ConfirmFactor != "email_otp" || m.ConfirmedAt == nil || !m.ConfirmedAt.Equal(t0) {
|
|
t.Fatalf("after confirm = %+v, %v; want confirmed by email_otp at %v", m, err, t0)
|
|
}
|
|
if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "h-first", t0.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("IssueMigrationCode: %v", err)
|
|
}
|
|
if err := repo.IssueMigrationCode(ctx, src.ID, bystander.ID, "h-again", t0.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) {
|
|
t.Fatalf("second issue = %v, want ErrConflict", err)
|
|
}
|
|
m, err = repo.MigrationForSource(ctx, src.ID)
|
|
if err != nil || m.State != "code_issued" || m.TargetUserID != dst.ID || m.CodeExpiresAt == nil || !m.CodeExpiresAt.Equal(t0.Add(10*time.Minute)) {
|
|
t.Fatalf("after issue = %+v, %v; want code_issued for the target, expiring at +10m", m, err)
|
|
}
|
|
|
|
// Restarting supersedes the whole attempt: the code it issued is dead.
|
|
if err := repo.StartMigration(ctx, "mig2-"+sfx, src.ID, t0); err != nil {
|
|
t.Fatalf("restart: %v", err)
|
|
}
|
|
if m, err := repo.MigrationForSource(ctx, src.ID); err != nil || m.ID != "mig2-"+sfx || m.State != "initiated" {
|
|
t.Fatalf("after restart = %+v, %v; want mig2 initiated", m, err)
|
|
}
|
|
if n := liveMigrations(t, src.ID); n != 1 {
|
|
t.Fatalf("live migrations after restart = %d, want 1", n)
|
|
}
|
|
if _, _, err := repo.RedeemMigration(ctx, dst.ID, "h-first", t0); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("code from the superseded attempt = %v, want ErrLinkCodeInvalid", err)
|
|
}
|
|
|
|
if err := repo.ConfirmMigration(ctx, src.ID, "passkey", t0); err != nil {
|
|
t.Fatalf("confirm the restart: %v", err)
|
|
}
|
|
if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "h-second", t0.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("issue the restart: %v", err)
|
|
}
|
|
for _, bad := range []struct {
|
|
what, target, hash string
|
|
at time.Time
|
|
}{
|
|
{"another user with the code", bystander.ID, "h-second", t0},
|
|
{"the target with a wrong code", dst.ID, "h-wrong", t0},
|
|
{"the target at code expiry", dst.ID, "h-second", t0.Add(10 * time.Minute)},
|
|
{"the source itself", src.ID, "h-second", t0},
|
|
} {
|
|
if _, _, err := repo.RedeemMigration(ctx, bad.target, bad.hash, bad.at); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("redeem by %s = %v, want ErrLinkCodeInvalid", bad.what, err)
|
|
}
|
|
}
|
|
if owner := serverOwner(t, liveA); owner != src.ID {
|
|
t.Fatalf("a refused redeem moved %s to %s", liveA, owner)
|
|
}
|
|
|
|
source, moved, err := repo.RedeemMigration(ctx, dst.ID, "h-second", t0.Add(10*time.Minute-time.Second))
|
|
if err != nil {
|
|
t.Fatalf("RedeemMigration: %v", err)
|
|
}
|
|
sort.Strings(moved)
|
|
if source != src.ID || strings.Join(moved, ",") != liveA+","+liveB {
|
|
t.Fatalf("redeem = %s, %v; want %s, [%s %s]", source, moved, src.ID, liveA, liveB)
|
|
}
|
|
for name, want := range map[string]string{liveA: dst.ID, liveB: dst.ID, gone: src.ID, theirs: bystander.ID} {
|
|
if got := serverOwner(t, name); got != want {
|
|
t.Fatalf("owner of %s = %s, want %s", name, got, want)
|
|
}
|
|
}
|
|
var disabled, deleted bool
|
|
if err := db.QueryRow(`SELECT disabled, deleted_at IS NOT NULL FROM users WHERE id = $1`, src.ID).Scan(&disabled, &deleted); err != nil {
|
|
t.Fatalf("read source: %v", err)
|
|
}
|
|
if !disabled || !deleted {
|
|
t.Fatalf("source after redeem: disabled=%v deleted=%v, want both", disabled, deleted)
|
|
}
|
|
// The session is revoked outright, beyond failing through the dead account.
|
|
var srcRevoked bool
|
|
if err := db.QueryRow(`SELECT revoked_at IS NOT NULL FROM sessions WHERE token_hash = $1`, srcSession).Scan(&srcRevoked); err != nil {
|
|
t.Fatalf("read source session: %v", err)
|
|
}
|
|
if !srcRevoked {
|
|
t.Fatalf("source session after redeem is not revoked")
|
|
}
|
|
if _, err := repo.SessionUser(ctx, dstSession, t0); err != nil {
|
|
t.Fatalf("target session after redeem: %v", err)
|
|
}
|
|
var state string
|
|
var redeemedAt sql.NullTime
|
|
if err := db.QueryRow(`SELECT state, redeemed_at FROM account_migrations WHERE id = $1`, "mig2-"+sfx).Scan(&state, &redeemedAt); err != nil {
|
|
t.Fatalf("read migration: %v", err)
|
|
}
|
|
if state != "redeemed" || !redeemedAt.Valid || !redeemedAt.Time.Equal(t0.Add(10*time.Minute-time.Second)) {
|
|
t.Fatalf("migration after redeem: %s at %v, want redeemed at %v", state, redeemedAt, t0.Add(10*time.Minute-time.Second))
|
|
}
|
|
|
|
if _, _, err := repo.RedeemMigration(ctx, dst.ID, "h-second", t0); !errors.Is(err, api.ErrLinkCodeInvalid) {
|
|
t.Fatalf("replayed redeem = %v, want ErrLinkCodeInvalid", err)
|
|
}
|
|
if _, err := repo.MigrationForSource(ctx, src.ID); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("status after redeem = %v, want ErrNotFound", err)
|
|
}
|
|
if err := repo.StartMigration(ctx, "mig3-"+sfx, src.ID, t0); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("retired source starting again = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// Racing redeems of one code move the servers once; racing starts for one source
|
|
// all succeed and leave one live attempt.
|
|
func TestMigrationUnderConcurrency(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := mustNow()
|
|
for round := 0; round < 5; round++ {
|
|
src := newUser(t, "user", "migr-src")
|
|
dst := newUser(t, "user", "migr-dst")
|
|
srv := "mr-" + suffix(t)
|
|
seedOwnedServer(t, srv, src.ID, false)
|
|
startToCode(t, src.ID, dst.ID, "h-race", now, now.Add(10*time.Minute))
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 4)
|
|
moved := make([][]string, 4)
|
|
for i := range errs {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, moved[i], errs[i] = repo.RedeemMigration(ctx, dst.ID, "h-race", now)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
won, lost := 0, 0
|
|
for i, err := range errs {
|
|
switch {
|
|
case err == nil && len(moved[i]) == 1 && moved[i][0] == srv:
|
|
won++
|
|
case errors.Is(err, api.ErrLinkCodeInvalid):
|
|
lost++
|
|
default:
|
|
t.Fatalf("round %d: redeem %d = %v, %v", round, i, moved[i], err)
|
|
}
|
|
}
|
|
if won != 1 || lost != 3 {
|
|
t.Fatalf("round %d: racing redeems: %d won, %d refused; want 1, 3", round, won, lost)
|
|
}
|
|
if owner := serverOwner(t, srv); owner != dst.ID {
|
|
t.Fatalf("round %d: server owner = %s, want the target", round, owner)
|
|
}
|
|
}
|
|
|
|
src := newUser(t, "user", "migs-src")
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 8)
|
|
for i := range errs {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
errs[i] = repo.StartMigration(ctx, "migs-"+suffix(t), src.ID, now)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("racing start %d: %v", i, err)
|
|
}
|
|
}
|
|
if n := liveMigrations(t, src.ID); n != 1 {
|
|
t.Fatalf("live migrations after 8 racing starts = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// waitForLockWait polls until a statement starting with prefix is waiting on a lock.
|
|
func waitForLockWait(t *testing.T, prefix string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
var n int
|
|
if err := db.QueryRow(`SELECT count(*) FROM pg_stat_activity
|
|
WHERE datname = current_database() AND wait_event_type = 'Lock' AND ltrim(query) LIKE $1 || '%'`,
|
|
prefix).Scan(&n); err != nil {
|
|
t.Fatalf("read pg_stat_activity: %v", err)
|
|
}
|
|
if n > 0 {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("no statement %q waited on a lock within 5s", prefix)
|
|
}
|
|
|
|
// A restart that reaches the source while its redeem is in flight must not leave the
|
|
// retired account a live attempt: the redeem holds the migration row, the restart's
|
|
// supersede waits on it, and by the time the restart writes, the source is gone.
|
|
func TestStartMigrationBehindARedeem(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := mustNow()
|
|
src := newUser(t, "user", "migb-src")
|
|
dst := newUser(t, "user", "migb-dst")
|
|
srv := "mbr-" + suffix(t)
|
|
seedOwnedServer(t, srv, src.ID, false)
|
|
startToCode(t, src.ID, dst.ID, "h-behind", now, now.Add(10*time.Minute))
|
|
|
|
// Hold the source's server so the redeem stops after locking the migration row.
|
|
hold, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer hold.Rollback() //nolint:errcheck // no-op after commit
|
|
if _, err := hold.Exec(`SELECT 1 FROM servers WHERE name = $1 FOR UPDATE`, srv); err != nil {
|
|
t.Fatalf("hold server: %v", err)
|
|
}
|
|
redeemed := make(chan error, 1)
|
|
go func() {
|
|
_, _, err := repo.RedeemMigration(ctx, dst.ID, "h-behind", now)
|
|
redeemed <- err
|
|
}()
|
|
waitForLockWait(t, "UPDATE servers SET owner_id")
|
|
started := make(chan error, 1)
|
|
go func() { started <- repo.StartMigration(ctx, "migb-"+suffix(t), src.ID, now) }()
|
|
waitForLockWait(t, "DELETE FROM account_migrations")
|
|
if err := hold.Commit(); err != nil {
|
|
t.Fatalf("release server: %v", err)
|
|
}
|
|
if err := <-redeemed; err != nil {
|
|
t.Fatalf("redeem: %v", err)
|
|
}
|
|
if err := <-started; !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("restart behind the redeem = %v, want ErrNotFound", err)
|
|
}
|
|
if n := liveMigrations(t, src.ID); n != 0 {
|
|
t.Fatalf("live migrations for the retired source = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// ---- setup tokens (migration 0012) --------------------------------------------------
|
|
|
|
// A setup token redeems once, never at or after expiry, and racing redeems of one
|
|
// token yield one session.
|
|
func TestConsumeSetupTokenContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
u := newUser(t, "user", "setup")
|
|
t0 := mustNow().Truncate(time.Second)
|
|
create := func(hash string) {
|
|
t.Helper()
|
|
if err := repo.CreateSetupToken(ctx, hash, u.ID, t0.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("CreateSetupToken: %v", err)
|
|
}
|
|
}
|
|
once := "st-once-" + suffix(t)
|
|
create(once)
|
|
if got, err := repo.ConsumeSetupToken(ctx, once, t0); err != nil || got != u.ID {
|
|
t.Fatalf("redeem = %q, %v; want %s", got, err, u.ID)
|
|
}
|
|
if _, err := repo.ConsumeSetupToken(ctx, once, t0); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("replay = %v, want ErrNotFound", err)
|
|
}
|
|
if _, err := repo.ConsumeSetupToken(ctx, "st-never-"+suffix(t), t0); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("unknown token = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
late := "st-late-" + suffix(t)
|
|
create(late)
|
|
if _, err := repo.ConsumeSetupToken(ctx, late, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("redeem at expiry = %v, want ErrNotFound", err)
|
|
}
|
|
if got, err := repo.ConsumeSetupToken(ctx, late, t0.Add(10*time.Minute-time.Second)); err != nil || got != u.ID {
|
|
t.Fatalf("redeem a second before expiry = %q, %v; want %s (the refused try must not spend it)", got, err, u.ID)
|
|
}
|
|
|
|
raced := "st-race-" + suffix(t)
|
|
create(raced)
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 8)
|
|
for i := range errs {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, errs[i] = repo.ConsumeSetupToken(ctx, raced, t0)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
won, lost := 0, 0
|
|
for i, err := range errs {
|
|
switch {
|
|
case err == nil:
|
|
won++
|
|
case errors.Is(err, api.ErrNotFound):
|
|
lost++
|
|
default:
|
|
t.Fatalf("racing redeem %d: %v", i, err)
|
|
}
|
|
}
|
|
if won != 1 || lost != 7 {
|
|
t.Fatalf("racing redeems: %d won, %d refused; want 1, 7", won, lost)
|
|
}
|
|
}
|
|
|
|
// ---- remediation: end every session, drop every passkey -----------------------------
|
|
|
|
func TestRevokeAllUserSessionsIsScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := mustNow()
|
|
u := newUser(t, "user", "rall")
|
|
other := newUser(t, "user", "rall-other")
|
|
a := newSession(t, u.ID, "rall-a", now.Add(time.Hour))
|
|
b := newSession(t, u.ID, "rall-b", now.Add(time.Hour))
|
|
old := newSession(t, u.ID, "rall-old", now.Add(time.Hour))
|
|
theirs := newSession(t, other.ID, "rall-theirs", now.Add(time.Hour))
|
|
earlier := now.Add(-time.Hour).Truncate(time.Microsecond)
|
|
mustExec(t, `UPDATE sessions SET revoked_at = $2 WHERE token_hash = $1`, old, earlier)
|
|
|
|
if err := repo.RevokeAllUserSessions(ctx, u.ID); err != nil {
|
|
t.Fatalf("RevokeAllUserSessions: %v", err)
|
|
}
|
|
for _, h := range []string{a, b} {
|
|
if _, err := repo.SessionUser(ctx, h, now); !errors.Is(err, api.ErrNotFound) {
|
|
t.Fatalf("session %s after revoke-all = %v, want ErrNotFound", h, err)
|
|
}
|
|
}
|
|
if _, err := repo.SessionUser(ctx, theirs, now); err != nil {
|
|
t.Fatalf("another account's session after revoke-all: %v", err)
|
|
}
|
|
var revokedAt time.Time
|
|
if err := db.QueryRow(`SELECT revoked_at FROM sessions WHERE token_hash = $1`, old).Scan(&revokedAt); err != nil {
|
|
t.Fatalf("read old session: %v", err)
|
|
}
|
|
if !sameMicro(revokedAt, earlier) {
|
|
t.Fatalf("an already-revoked session's revoked_at moved from %v to %v", earlier, revokedAt)
|
|
}
|
|
if err := repo.RevokeAllUserSessions(ctx, newUser(t, "user", "rall-none").ID); err != nil {
|
|
t.Fatalf("revoke-all with no sessions = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteAllPasskeyCredentialsIsScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
seed := func(userID string) {
|
|
t.Helper()
|
|
mustExec(t, `INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key) VALUES ($1, $2, $3, 'pk')`,
|
|
"cred-"+suffix(t), userID, "cid-"+suffix(t))
|
|
}
|
|
count := func(userID string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.QueryRow(`SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, userID).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
u := newUser(t, "user", "pkall")
|
|
other := newUser(t, "user", "pkall-other")
|
|
seed(u.ID)
|
|
seed(u.ID)
|
|
seed(other.ID)
|
|
if err := repo.DeleteAllPasskeyCredentialsForUser(ctx, u.ID); err != nil {
|
|
t.Fatalf("DeleteAllPasskeyCredentialsForUser: %v", err)
|
|
}
|
|
if got, theirs := count(u.ID), count(other.ID); got != 0 || theirs != 1 {
|
|
t.Fatalf("after delete-all: %d left for the account, %d for the other; want 0, 1", got, theirs)
|
|
}
|
|
if err := repo.DeleteAllPasskeyCredentialsForUser(ctx, u.ID); err != nil {
|
|
t.Fatalf("delete-all with none left = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// ---- username reclaim (migration 0006) ----------------------------------------------
|
|
|
|
// A reclaim bars the squatter's UUID (never the contested name) and stashes its data
|
|
// once: a retry keeps the first window and handle, and a failed stash leaves no bar.
|
|
func TestReclaimUsernameContract(t *testing.T) {
|
|
ctx := context.Background()
|
|
t0 := mustNow().Truncate(time.Second)
|
|
squatter, genuine := testUUID(t), testUUID(t)
|
|
name := "Notch" + suffix(t)[:6]
|
|
holdID := "hold-" + suffix(t)
|
|
|
|
got, err := repo.ReclaimUsername(ctx, holdID, squatter, name, "ref-1", t0.Add(30*24*time.Hour))
|
|
if err != nil || !got.Equal(t0.Add(30*24*time.Hour)) {
|
|
t.Fatalf("reclaim = %v, %v; want %v", got, err, t0.Add(30*24*time.Hour))
|
|
}
|
|
for uuid, want := range map[string]bool{squatter: true, genuine: false} {
|
|
if barred, err := repo.IsUsernameBlacklisted(ctx, uuid); err != nil || barred != want {
|
|
t.Fatalf("IsUsernameBlacklisted(%s) = %v, %v; want %v", uuid, barred, err, want)
|
|
}
|
|
}
|
|
|
|
got, err = repo.ReclaimUsername(ctx, "hold-retry-"+suffix(t), squatter, name, "ref-2", t0.Add(60*24*time.Hour))
|
|
if err != nil || !got.Equal(t0.Add(30*24*time.Hour)) {
|
|
t.Fatalf("retried reclaim = %v, %v; want the first window %v", got, err, t0.Add(30*24*time.Hour))
|
|
}
|
|
var holds int
|
|
var ref string
|
|
if err := db.QueryRow(`SELECT count(*), max(data_ref) FROM player_data_holds WHERE mc_uuid = $1`, squatter).Scan(&holds, &ref); err != nil {
|
|
t.Fatalf("read holds: %v", err)
|
|
}
|
|
if holds != 1 || ref != "ref-1" {
|
|
t.Fatalf("holds after a retry: %d with ref %q, want 1 with ref-1", holds, ref)
|
|
}
|
|
|
|
unarchived := testUUID(t)
|
|
if _, err := repo.ReclaimUsername(ctx, "hold-bare-"+suffix(t), unarchived, name, "", t0.Add(30*24*time.Hour)); err != nil {
|
|
t.Fatalf("reclaim without a data ref: %v", err)
|
|
}
|
|
var bare sql.NullString
|
|
if err := db.QueryRow(`SELECT data_ref FROM player_data_holds WHERE mc_uuid = $1`, unarchived).Scan(&bare); err != nil {
|
|
t.Fatalf("read bare hold: %v", err)
|
|
}
|
|
if bare.Valid {
|
|
t.Fatalf("empty data ref stored as %q, want NULL", bare.String)
|
|
}
|
|
|
|
// The stash fails (its id is taken), so the bar written before it rolls back.
|
|
orphan := testUUID(t)
|
|
if _, err := repo.ReclaimUsername(ctx, holdID, orphan, name, "ref-3", t0.Add(30*24*time.Hour)); err == nil {
|
|
t.Fatalf("reclaim reusing a hold id = nil, want an error")
|
|
}
|
|
if barred, err := repo.IsUsernameBlacklisted(ctx, orphan); err != nil || barred {
|
|
t.Fatalf("UUID barred by a reclaim whose stash failed: %v, %v; want false", barred, err)
|
|
}
|
|
}
|