fix(api): in-game identity resolution and link takeover ignore dead accounts

UserByMCUUID now resolves only live accounts: claim, menu, wake
authorization, op-login vouch and the QR link-status poll treat a
disabled or soft-deleted link holder exactly like an unlinked UUID
instead of a retired identity. VerifyLinkCode lets a soft-deleted
link be taken over by a fresh in-game code (the deleted account is
gone, e.g. a migrated source), while a disabled holder still 409s so
the lockout is not bypassable; failed attempts still do not consume
the code. Fake repo and pgint coverage pin both branches.
This commit is contained in:
Lemon-miaow committed 2026-09-23 05:40:29 +08:00
1 parent 3ffa3f5318
commit bb9798e32c
5 files changed
+182 -7

No files matched your search

+73
View File
@@ -532,6 +532,20 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
if err := repo.LinkAccount(ctx, u.ID, mc, "mojang"); err != nil {
t.Fatalf("LinkAccount: %v", err)
}
// A disabled account's intact link carries no in-game standing (the doors read
// it like an unlinked UUID), and resolves again once re-enabled.
if err := repo.SetUserDisabled(ctx, u.ID, true); err != nil {
t.Fatalf("disable with link: %v", err)
}
if _, err := repo.UserByMCUUID(ctx, mc); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("UserByMCUUID(disabled) = %v, want ErrNotFound", err)
}
if err := repo.SetUserDisabled(ctx, u.ID, false); err != nil {
t.Fatalf("re-enable with link: %v", err)
}
if id, err := repo.UserByMCUUID(ctx, mc); err != nil || id != u.ID {
t.Fatalf("UserByMCUUID(re-enabled) = %q, %v; want %q", id, err, u.ID)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key) VALUES ($1,$2,$3,'pk')`,
"cred-"+suffix(t), u.ID, "cid-"+suffix(t)); err != nil {
@@ -564,6 +578,65 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
}
}
// VerifyLinkCode's takeover rule: a fresh in-game code (proof the caller holds the
// UUID) lets a live account take over a link whose account was SOFT-DELETED — the
// migrated-source case, whose retire keeps the link but kills the account — while a
// merely DISABLED holder keeps its identity (takeover there would bypass the
// lockout) and the refused code survives.
func TestVerifyLinkCodeTakesOverDeletedLink(t *testing.T) {
ctx := context.Background()
now := mustNow()
// The retired source: linked, then retired the way RedeemMigration retires one.
src := newUser(t, "user", "retire-src")
mc := testUUID(t)
if err := repo.LinkAccount(ctx, src.ID, mc, "mojang"); err != nil {
t.Fatalf("LinkAccount(src): %v", err)
}
if _, err := db.ExecContext(ctx,
`UPDATE users SET disabled = true, deleted_at = now() WHERE id = $1`, src.ID); err != nil {
t.Fatalf("retire src: %v", err)
}
taker := newUser(t, "user", "taker")
code := "tk-" + suffix(t)
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(5*time.Minute)); err != nil {
t.Fatalf("CreateLinkCode: %v", err)
}
if _, _, err := repo.VerifyLinkCode(ctx, taker.ID, code, now); err != nil {
t.Fatalf("takeover verify: %v", err)
}
if id, err := repo.UserByMCUUID(ctx, mc); err != nil || id != taker.ID {
t.Fatalf("after takeover UserByMCUUID = %q, %v; want %q", id, err, taker.ID)
}
// A disabled (not deleted) holder keeps the identity; the conflict arm must not
// consume the code and must leave the link where it was.
locked := newUser(t, "user", "locked")
mc2 := testUUID(t)
if err := repo.LinkAccount(ctx, locked.ID, mc2, "mojang"); err != nil {
t.Fatalf("LinkAccount(locked): %v", err)
}
if err := repo.SetUserDisabled(ctx, locked.ID, true); err != nil {
t.Fatalf("disable locked: %v", err)
}
code2 := "lk-" + suffix(t)
if err := repo.CreateLinkCode(ctx, code2, mc2, "mojang", now.Add(5*time.Minute)); err != nil {
t.Fatalf("CreateLinkCode(code2): %v", err)
}
if _, _, err := repo.VerifyLinkCode(ctx, taker.ID, code2, now); !errors.Is(err, api.ErrConflict) {
t.Fatalf("takeover of a disabled holder = %v, want ErrConflict", err)
}
var stillLinked string
if err := db.QueryRowContext(ctx, `SELECT user_id FROM account_links WHERE mc_uuid = $1`, mc2).Scan(&stillLinked); err != nil || stillLinked != locked.ID {
t.Fatalf("disabled holder's link moved to %q, %v; want %q", stillLinked, err, locked.ID)
}
var codeRows int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM account_link_codes WHERE code = $1`, code2).Scan(&codeRows); err != nil || codeRows != 1 {
t.Fatalf("refused code rows = %d, %v; want 1 (not consumed)", codeRows, err)
}
}
// 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.