Files
Felis/internal/api/handlers_account_test.go

397 lines
18 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// acctBody decodes a success body into a generic object for field assertions.
func acctBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
return m
}
// TestAccountLinkVertical walks the whole §10 flow across both faces and proves
// the point of the slice: linking reconnects the claim vertical that handleClaim
// (handlers_user.go:100) otherwise dead-ends with 412 not_linked.
func TestAccountLinkVertical(t *testing.T) {
const mcUUID = "11111111-1111-1111-1111-111111111111"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
// Pre-arm the downstream claim gates so a *successful* claim becomes possible
// the instant the link gate clears — that is what demonstrates the unblock.
repo.quota["u1"] = true
repo.claimOK["survival"] = true
api := newTestAPI(repo, claimCluster())
api.External = staticExternal{p: user}
ih := api.InternalHandler()
eh := api.ExternalHandler()
// Before linking, the claim dead-ends at the link gate.
if w := do(eh, "POST", "/api/v1/servers/survival/claim", "", nil); w.Code != http.StatusPreconditionFailed || decodeErr(t, w) != "not_linked" {
t.Fatalf("pre-link claim: code = %d body %s, want 412 not_linked", w.Code, w.Body.String())
}
// 1) in-game /link mints a one-time code (internal face).
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("mint code: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if len(code) != linkCodeLen {
t.Fatalf("minted code %q: len = %d, want %d", code, len(code), linkCodeLen)
}
// 2) the player submits the code on the panel (external face).
w = do(eh, "POST", "/api/v1/account/link/verify", `{"code":"`+code+`"}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID || b["auth_source"] != authSourceMojang {
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s auth_source:%s", b, mcUUID, authSourceMojang)
}
// The link is audited as account.link by the principal's Access email.
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "[email protected]" {
t.Fatalf("link audit not written as expected: %+v", repo.audits)
}
// 3) the identical claim now passes the link gate and succeeds — the vertical
// is reconnected, which is the whole reason this slice exists.
if w := do(eh, "POST", "/api/v1/servers/survival/claim", "", nil); w.Code != http.StatusOK {
t.Fatalf("post-link claim: code = %d body %s, want 200", w.Code, w.Body.String())
}
}
// TestCreateLinkCode covers the internal mint endpoint: input validation, strict
// decoding, and that a real code lands in the store with the API-clock TTL.
func TestCreateLinkCode(t *testing.T) {
const mcUUID = "22222222-2222-2222-2222-222222222222"
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
ih := api.InternalHandler()
t.Run("missing uuid -> 400", func(t *testing.T) {
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("unknown field rejected (no user_id smuggling)", func(t *testing.T) {
// The schema deliberately has no user_id on a code; a caller must not be able
// to introduce one via an extra field.
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`","user_id":"u1"}`, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("strict decode must reject unknown field, code = %d (%s)", w.Code, w.Body.String())
}
})
t.Run("mints into store with TTL", func(t *testing.T) {
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
rec, ok := repo.linkCodes[code]
if !ok {
t.Fatalf("minted code %q was not persisted", code)
}
if rec.mcUUID != mcUUID {
t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID)
}
// An omitted auth_source defaults to the Mojang-priority source.
if rec.authSource != authSourceMojang {
t.Errorf("default authSource = %q, want %q", rec.authSource, authSourceMojang)
}
if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) {
t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want)
}
for _, c := range code {
if !strings.ContainsRune(linkCodeAlphabet, c) {
t.Errorf("code %q contains out-of-alphabet rune %q", code, c)
}
}
})
t.Run("panel_url points at the web console", func(t *testing.T) {
// The mint response carries the redeem address so every plugin renders the
// same hostname from one source of truth (derived console.<root> here).
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["panel_url"]; got != "https://console."+testRoot {
t.Errorf("panel_url = %v, want https://console.%s", got, testRoot)
}
})
t.Run("omitted auth_source with a v3 UUID derives thirdparty", func(t *testing.T) {
// A felis-nano rewrite is a name-based UUIDv3; the version nibble alone must
// classify it so no Java plugin has to learn the auth_source field.
const v3UUID = "33333333-3333-3333-8333-333333333333"
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+v3UUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
t.Errorf("derived authSource = %q, want %q", rec.authSource, authSourceThirdParty)
}
})
t.Run("explicit thirdparty is stored", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
t.Errorf("stored authSource = %q, want %q", rec.authSource, authSourceThirdParty)
}
})
t.Run("unrecognised auth_source -> 400", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"litebans"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
}
// TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a
// past-dated code directly: the test clock is frozen, so there is nothing to
// "advance" — planting an already-expired code is the only way to exercise the
// expiry branch.
func TestLinkVerifyRejections(t *testing.T) {
const mcUUID = "33333333-3333-3333-3333-333333333333"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
mk := func(repo *fakeRepo) http.Handler {
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
return api.ExternalHandler()
}
t.Run("empty code -> 400 bad_request", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/link/verify", `{"code":""}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("whitespace code -> 400 bad_request", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/link/verify", `{"code":" "}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("unknown field -> 400", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/link/verify", `{"token":"ABCDEFGH"}`, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("strict decode must reject unknown field, code = %d", w.Code)
}
})
t.Run("unknown code -> 400 invalid_code", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/link/verify", `{"code":"NEVERMINT"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("expired code -> 400 invalid_code", func(t *testing.T) {
repo := newFakeRepo()
// One second before the frozen test clock (time.Unix(1_700_000_000, 0)).
repo.linkCodes["EXPIREDXY"] = fakeLinkCode{mcUUID: mcUUID, expiresAt: time.Unix(1_699_999_999, 0)}
w := do(mk(repo), "POST", "/api/v1/account/link/verify", `{"code":"EXPIREDXY"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if _, ok := repo.linkCodes["EXPIREDXY"]; !ok {
t.Error("an expired code must not be consumed")
}
})
t.Run("uuid linked to another user -> 409 already_linked", func(t *testing.T) {
repo := newFakeRepo()
repo.links[mcUUID] = "someone-else"
repo.linkCodes["FRESHCOD"] = fakeLinkCode{mcUUID: mcUUID, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)}
w := do(mk(repo), "POST", "/api/v1/account/link/verify", `{"code":"FRESHCOD"}`, nil)
if w.Code != http.StatusConflict || decodeErr(t, w) != "already_linked" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
// A wrong-user submission must not burn the real owner's pending code.
if _, ok := repo.linkCodes["FRESHCOD"]; !ok {
t.Error("a conflicting verify must not consume the code")
}
})
}
// TestLinkVerifyIdempotent re-verifies the same (user, uuid) pair with a fresh
// code and expects a clean 200, not a self-conflict.
func TestLinkVerifyIdempotent(t *testing.T) {
const mcUUID = "44444444-4444-4444-4444-444444444444"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.links[mcUUID] = "u1" // already linked to THIS user
repo.linked["u1"] = true
repo.linkCodes["REVERIFYX"] = fakeLinkCode{mcUUID: mcUUID, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"REVERIFYX"}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("idempotent re-verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["mc_uuid"]; got != mcUUID {
t.Errorf("mc_uuid = %v, want %s", got, mcUUID)
}
if repo.links[mcUUID] != "u1" {
t.Errorf("links[%s] = %q, want u1", mcUUID, repo.links[mcUUID])
}
}
// 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, authSource: "mojang", 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.
func TestLinkAuthSourcePropagates(t *testing.T) {
const mcUUID = "55555555-5555-5555-5555-555555555555"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
// Mint in-game with the thirdparty Yggdrasil source.
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(api.InternalHandler(), "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("mint: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
// Verify on the web: the response and the stored link must both carry thirdparty.
w = do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"`+code+`"}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["auth_source"]; got != authSourceThirdParty {
t.Errorf("verify body auth_source = %v, want %q", got, authSourceThirdParty)
}
if got := repo.linkAuthSource[mcUUID]; got != authSourceThirdParty {
t.Errorf("stored link auth_source = %q, want %q", got, authSourceThirdParty)
}
}
// TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports
// link state and instructions, and never mints (it has no UUID to mint against).
func TestLinkStart(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
mk := func(repo *fakeRepo) http.Handler {
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
return api.ExternalHandler()
}
t.Run("not linked", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/link/start", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["linked"] != false {
t.Errorf("linked = %v, want false", b["linked"])
}
if s, _ := b["instructions"].(string); s == "" {
t.Error("start must return non-empty instructions")
}
})
t.Run("already linked", func(t *testing.T) {
repo := newFakeRepo()
repo.linked["u1"] = true
w := do(mk(repo), "POST", "/api/v1/account/link/start", "", nil)
if b := acctBody(t, w); b["linked"] != true {
t.Errorf("linked = %v, want true", b["linked"])
}
})
}
// TestAccountLinkFaceSeparation enforces the schema-forced face split: the code
// generator is internal-only, and verify/start are web-only. Crossing those
// faces must 404, not silently work.
func TestAccountLinkFaceSeparation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.External = staticExternal{p: user}
ih := api.InternalHandler()
eh := api.ExternalHandler()
// The generator must not exist on the web face — a logged-in user could
// otherwise mint a code for a UUID they never proved they own.
if w := do(eh, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"x"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("code endpoint on external face: code = %d, want 404", w.Code)
}
// verify / start are web-only: they require a logged-in principal the internal
// face never carries.
if w := do(ih, "POST", "/api/v1/account/link/verify", `{"code":"ABCDEFGH"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("verify endpoint on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/account/link/start", "", nil); w.Code != http.StatusNotFound {
t.Errorf("start endpoint on internal face: code = %d, want 404", w.Code)
}
}