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

+54
View File
@@ -1,7 +1,9 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -252,6 +254,58 @@ func TestLinkVerifyIdempotent(t *testing.T) {
}
}
// A link whose account was SOFT-DELETED is unclaimed: a fresh in-game code lets a
// live account take it over (the migrated-source path — retire keeps the link but
// kills the account), while a merely disabled holder keeps its identity so the
// lockout cannot be re-linked away, and neither dead link has in-game standing
// (UserByMCUUID reads it exactly like an unlinked UUID). Audit #33's in-game half.
func TestLinkVerifyTakesOverDeletedLinkOnly(t *testing.T) {
ctx := context.Background()
const mcGone = "55555555-5555-5555-5555-555555555555"
const mcLocked = "66666666-6666-6666-6666-666666666666"
user := &Principal{UserID: "u-take", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u-gone", Username: "gone", Email: "[email protected]", Role: "user"})
repo.seedUser(UserView{ID: "u-locked", Username: "locked", Email: "[email protected]", Role: "user"})
if err := repo.DeleteUser(ctx, "u-gone", "test"); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
repo.links[mcGone] = "u-gone" // a retired source's link outlives the account
if _, err := repo.UserByMCUUID(ctx, mcGone); !errors.Is(err, ErrNotFound) {
t.Fatalf("UserByMCUUID(deleted link) = %v, want ErrNotFound (no in-game standing)", err)
}
repo.linkCodes["TAKEOVER"] = fakeLinkCode{mcUUID: mcGone, expiresAt: time.Unix(1_700_000_600, 0)}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"TAKEOVER"}`, nil); w.Code != http.StatusOK {
t.Fatalf("takeover verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if repo.links[mcGone] != "u-take" {
t.Errorf("links[%s] = %q after takeover, want u-take", mcGone, repo.links[mcGone])
}
// A disabled (not deleted) holder keeps the identity: 409, code survives, link unmoved.
if err := repo.SetUserDisabled(ctx, "u-locked", true); err != nil {
t.Fatalf("disable: %v", err)
}
repo.links[mcLocked] = "u-locked"
repo.linkCodes["LOCKED12"] = fakeLinkCode{mcUUID: mcLocked, expiresAt: time.Unix(1_700_000_600, 0)}
if _, err := repo.UserByMCUUID(ctx, mcLocked); !errors.Is(err, ErrNotFound) {
t.Fatalf("UserByMCUUID(disabled link) = %v, want ErrNotFound", err)
}
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"LOCKED12"}`, nil); w.Code != http.StatusConflict {
t.Fatalf("takeover of a disabled holder: code = %d, want 409 (%s)", w.Code, w.Body.String())
}
if repo.links[mcLocked] != "u-locked" {
t.Errorf("disabled holder's link moved to %q", repo.links[mcLocked])
}
if _, ok := repo.linkCodes["LOCKED12"]; !ok {
t.Error("refused verify consumed the code")
}
}
// TestLinkAuthSourcePropagates proves auth_source survives the whole §10 flow: a
// thirdparty source captured in-game at mint reaches the durable link and the
// verify response — the value the web side can never originate itself.