Files
Felis/internal/api/handlers_setup_test.go
flyemoji c4c964578e fix(setup): stop the forced-onboarding gate trapping players who have no email
setup_required is what the SPA polls to decide whether the onboarding wall is
still owed, and it disagreed with the middleware that actually enforces the
wall. requireOnboarded lifts on a verified email OR an enrolled passkey;
setup_required answered `u.Email == "" || !hasPasskey`. A console-tier player
joins through the bind-code door with no email at all — by design, there is no
SMTP at that point — so the email term never clears and the SPA keeps them on
the setup screen forever, even after they enroll the passkey that already
unlocked the API for them.

The predicate now lives in one place (setupRequired) and both endpoints call
it, so the next edit to the unlock condition cannot drift them apart again.
Keying it on EmailVerified rather than email presence is the deliberate part:
presence is exactly the term that trapped the no-email player, and it was also
wrong on its own terms — an unverified address is not an authentication
factor, so it was never what the lockdown could safely lift on.

Also lands the regression test for the mechanism behind the live claim-403
report: /me/servers answers 200 for a bind-onboarded player (which is why the
dashboard renders the 认领 button at all) while claim, wake and status all
answer 403 with code "setup_required" — i.e. the refusal comes from
requireOnboarded before the handler, not from isOwnerOrAdmin inside it, which
would have said "forbidden". Enrolling a passkey and changing nothing else
lifts all three, which isolates the gate as the sole cause. The backend authz
is correct; the button that leads a locked-down player into a 403 is the
frontend's to hide.
2026-07-22 14:40:28 +09:00

149 lines
6.7 KiB
Go

package api
import (
"encoding/json"
"net/http"
"testing"
)
// TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email
// RECORDED + passkey ENROLLED, never on email verification (the bootstrap has no SMTP,
// so the Owner's address is stored unverified). The lockdown must therefore lift on a
// passkey, not on email_verified — otherwise the unverified Owner could never leave the
// wizard to reach the Settings/SMTP page.
func TestSetupNoSMTPFlow(t *testing.T) {
repo := newFakeRepo()
// Fresh Owner: admin, no email, unverified, no passkey — exactly post-CompleteOwnerSetup.
repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"}
api := newTestAPI(repo, newFakeCluster())
// A lockdown session principal: authenticated by session, email not yet verified.
api.External = staticExternal{p: &Principal{UserID: "o1", Role: "admin", ViaSession: true}}
h := api.ExternalHandler()
status := func(t *testing.T) map[string]any {
t.Helper()
w := do(h, "GET", "/api/v1/auth/setup/status", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("status body not JSON: %v", err)
}
return got
}
// 1. Nothing done → setup required, no email, no passkey.
if s := status(t); s["setup_required"] != true || s["email"] != "" || s["has_passkey"] != false {
t.Fatalf("fresh owner status = %v, want setup_required=true email=\"\" has_passkey=false", s)
}
// 2. Record the email — NO OTP. The row is written but email_verified stays false.
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("set-email code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if u := repo.staff["owner"]; u.Email != "[email protected]" || u.EmailVerified {
t.Fatalf("after record: email=%q verified=%v, want the address recorded and UNVERIFIED", u.Email, u.EmailVerified)
}
// 3. Email recorded but no passkey → STILL required (email verification is not the gate).
if s := status(t); s["setup_required"] != true || s["email"] != "[email protected]" {
t.Fatalf("email-only status = %v, want setup_required=true (passkey still missing)", s)
}
// 4. The lockdown must still fence a non-SetupAllowed route: no passkey ⇒ 403 setup_required.
w = do(h, "POST", "/api/v1/me/submissions", `{}`, jsonHeader)
if w.Code != http.StatusForbidden || errCode(w.Body.Bytes()) != "setup_required" {
t.Fatalf("pre-passkey locked route: code=%d err=%q, want 403 setup_required (%s)", w.Code, errCode(w.Body.Bytes()), w.Body.String())
}
// 5. Enroll a passkey (the Owner's only pre-SMTP credential).
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "o1", CredentialID: "cred1"}
// 6. Passkey present ⇒ setup complete AND the lockdown lifts (the route no longer 403s setup_required).
if s := status(t); s["setup_required"] != false || s["has_passkey"] != true {
t.Fatalf("post-passkey status = %v, want setup_required=false has_passkey=true", s)
}
w = do(h, "POST", "/api/v1/me/submissions", `{}`, jsonHeader)
if w.Code == http.StatusForbidden && errCode(w.Body.Bytes()) == "setup_required" {
t.Fatalf("post-passkey the lockdown did NOT lift: route still 403 setup_required")
}
}
// TestSetEmailClearsVerified pins the invariant that recording an unproven address
// drops any prior verification: /account/email is app-tier + SetupAllowed, so any
// authenticated session can reach it — an already-verified caller who changes their
// address must NOT keep email_verified=true asserting a proof they never gave. Only
// VerifyEmailOTP (which proves the address) may set that flag.
func TestSetEmailClearsVerified(t *testing.T) {
repo := newFakeRepo()
// A fully onboarded staff account: email already proven.
repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "[email protected]", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true}}
h := api.ExternalHandler()
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("set-email code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if u := repo.staff["u"]; u.Email != "[email protected]" || u.EmailVerified {
t.Fatalf("after record: email=%q verified=%v, want new address recorded and verification CLEARED", u.Email, u.EmailVerified)
}
}
// TestSetupCompletesForNoEmailPlayer pins the console-tier player fix: a bind-code
// player joins with NO email (by design — no SMTP) and completes forced onboarding by
// enrolling a passkey alone. setup_required MUST then report false, in lockstep with
// requireOnboarded lifting (api.go:615). The OLD predicate (email == "" || !hasPasskey)
// trapped exactly this state — email is empty forever, so it looped the player.
func TestSetupCompletesForNoEmailPlayer(t *testing.T) {
repo := newFakeRepo()
// A console-tier player: role=user, no email ever, no passkey.
repo.staff["p"] = &StaffUser{ID: "p1", Username: "player", Role: "user"}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "p1", Role: "user", ViaSession: true}}
h := api.ExternalHandler()
status := func(t *testing.T) map[string]any {
t.Helper()
w := do(h, "GET", "/api/v1/auth/setup/status", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("status body not JSON: %v", err)
}
return got
}
// No email, no passkey → forced onboarding still owed.
if s := status(t); s["setup_required"] != true || s["email"] != "" {
t.Fatalf("fresh player status = %v, want setup_required=true email=\"\"", s)
}
// Enroll a passkey — the ONLY step a no-email player can complete.
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "p1", CredentialID: "cred1"}
// Setup is now COMPLETE even though email stays empty. The OLD predicate returned
// true here (email == "") and looped the player forever.
if s := status(t); s["setup_required"] != false || s["has_passkey"] != true || s["email"] != "" {
t.Fatalf("post-passkey player status = %v, want setup_required=false has_passkey=true email=\"\"", s)
}
}
// errCode returns the error.code of a JSON error body, or "" if body is not one (a
// non-failing decodeErr for cases where the response may be a success).
func errCode(body []byte) string {
var raw map[string]map[string]string
if json.Unmarshal(body, &raw) != nil {
return ""
}
return raw["error"]["code"]
}