feat(auth): migrate console login to passwordless

Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
This commit is contained in:
flyemoji committed 2026-07-04 21:47:12 +09:00
1 parent 627883e89a
commit 0c1cc598c1
46 files changed
+5554 -1651

No files matched your search

+509
View File
@@ -0,0 +1,509 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// op.console STAFF login tests (spec §B op-login). The two-factor door's load-bearing
// properties, in the order the flow meets them:
//
// - Two factors, both required. finish mints a session only when the mailed op_login
// code verifies AND an in-game admin has approved the request; neither alone works.
// - Neutral start. A non-staff or unknown address gets a 202 with a plausible but
// non-persisted request_id and nothing mailed, so start is not a staff oracle.
// - Neutral status. An unknown/expired/consumed handle reads approved:false exactly
// like a real request awaiting approval, so a fabricated handle is not an oracle.
// - Uniform finish failure. Unknown handle / not-approved / wrong code / lost race
// all collapse to one op_login_invalid envelope; an early-but-correct code is
// preserved (approval is read before the code is consumed), and a wrong code costs
// an attempt without burning the approval.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
// API's own user table, defence in depth over velocity's in-game op gate.
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
// seedOpLoginAPI wires the op.console door: local sessions enabled and a single staff
// admin "op" (id a1) whose proven address is stored in MIXED case (so the mint-against-
// stored-casing contract is exercised by default) and whose in-game UUID opUUID is
// linked, so the admin can act as an in-game approver.
func seedOpLoginAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["op"] = &StaffUser{
ID: "a1", Username: "op", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
repo.links[opUUID] = "a1"
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
return api, repo, mailer
}
// startOp / statusOp / finishOp drive the three public browser calls; approveOp drives
// the internal in-game vouch. They return the recorder so each test asserts its own
// codes and bodies.
func startOp(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"`+email+`"}`, jsonHeader)
}
func statusOp(eh http.Handler, id string) *httptest.ResponseRecorder {
return do(eh, "GET", "/api/v1/auth/op-login/status/"+id, "", nil)
}
func finishOp(eh http.Handler, id, code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/finish",
`{"request_id":"`+id+`","code":"`+code+`"}`, jsonHeader)
}
func approveOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
return do(ih, "POST", "/api/v1/internal/op-login/"+id+"/approve",
`{"approver_uuid":"`+approverUUID+`"}`, nil)
}
// TestOpLoginVertical walks the whole two-factor slice end to end: start mails a code
// (purpose op_login) to the staff address of record and mints a pending request; the
// browser polls status until an in-game admin approves; finish redeems code+approval
// into the same host-only session the other doors mint. All three legs audit by the
// account's username, and the request is single-use.
func TestOpLoginVertical(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh := api.ExternalHandler()
ih := api.InternalHandler()
// 1) start: 202 with a request handle + expiry, never the code itself.
w := startOp(eh, "[email protected]")
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
reqID, _ := b["request_id"].(string)
if reqID == "" {
t.Fatal("start must return a request_id")
}
if _, leaked := b["code"]; leaked {
t.Error("start response must NEVER carry the code")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("start must report expires_at")
}
// The code goes to the STORED casing (address of record), under the op_login purpose.
if mailer.calls != 1 || mailer.email != "[email protected]" {
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
mailer.calls, mailer.email)
}
code := mailer.code
if len(repo.otps) != 1 {
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
}
for _, o := range repo.otps {
if o.purpose != otpPurposeOpLogin {
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeOpLogin)
}
}
// Exactly one pending request row, owned by the staff account.
if len(repo.opLogins) != 1 {
t.Fatalf("op_login_requests rows = %d, want 1", len(repo.opLogins))
}
if got := repo.opLogins[reqID]; got == nil || got.userID != "a1" || got.status != "pending" {
t.Fatalf("request row = %+v, want {userID:a1, status:pending}", got)
}
// 2) status before approval: not approved yet.
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
t.Fatalf("status before approval: approved = %v, want false", sb["approved"])
}
// 3) finish before approval is REFUSED and must NOT burn the code (approval is read
// before the code is consumed).
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("finish before approval: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Fatal("no session may be minted before approval")
}
// 4) an in-game admin approves via the internal face.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if ab := acctBody(t, statusOp(eh, reqID)); ab["approved"] != true {
t.Fatalf("status after approval: approved = %v, want true", ab["approved"])
}
// 5) finish with the preserved code: session minted, role=admin.
w = finishOp(eh, reqID, code)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
vb := acctBody(t, w)
if vb["user_id"] != "a1" || vb["role"] != "admin" {
t.Fatalf("finish body = %v, want user_id:a1 role:admin", vb)
}
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "a1" {
t.Fatalf("session row for the cookie = %+v (ok=%v), want userID a1", s, ok)
}
// Three audits by "op": otp_sent (start), approved (in-game vouch), op_login (finish).
if n := len(repo.audits); n != 3 {
t.Fatalf("want 3 audits, got %d: %+v", n, repo.audits)
}
wantActions := []string{"auth.op_login.otp_sent", "auth.op_login.approved", "auth.op_login"}
for i, want := range wantActions {
if repo.audits[i].Action != want || repo.audits[i].Actor != "op" {
t.Errorf("audit[%d] = %+v, want action %q by op", i, repo.audits[i], want)
}
}
// 6) single-use: the consumed request finishes no second time, and status flips back
// to approved:false (consumed).
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("replay finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
t.Errorf("status after consume: approved = %v, want false", sb["approved"])
}
}
// TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is
// the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying
// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient
// cooldown — so neither the response nor the throttle tells a caller who is staff.
func TestOpLoginStartNeutral(t *testing.T) {
check := func(t *testing.T, seed func(*fakeRepo), email string) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
if seed != nil {
seed(repo)
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
eh := api.ExternalHandler()
w := startOp(eh, email)
if w.Code != http.StatusAccepted {
t.Fatalf("neutral start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if id, _ := b["request_id"].(string); id == "" {
t.Error("neutral start must still return a plausible request_id")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("neutral start must still return expires_at")
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 || len(repo.audits) != 0 {
t.Errorf("neutral start must mint/mail/audit nothing: reqs=%d otps=%d mails=%d audits=%d",
len(repo.opLogins), len(repo.otps), mailer.calls, len(repo.audits))
}
// The reservation is KEPT: re-probing the same address is throttled like a resend.
if w := startOp(eh, email); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
}
t.Run("unknown address", func(t *testing.T) {
check(t, nil, "[email protected]")
})
t.Run("non-staff (role=user) address is ignored by the staff door", func(t *testing.T) {
check(t, func(repo *fakeRepo) {
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
}, "[email protected]")
})
}
// TestOpLoginStatusNeutral proves status is never an enumeration oracle: it returns
// approved:true ONLY for a genuinely approved, live, unconsumed request, and
// approved:false (never 404) for an unknown, expired, denied, or consumed handle — all
// indistinguishable from a real request still awaiting approval.
func TestOpLoginStatusNeutral(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
future := time.Unix(1_700_000_600, 0)
past := time.Unix(1_699_999_999, 0)
repo.opLogins["pending"] = &fakeOpLogin{id: "pending", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: future}
repo.opLogins["expired"] = &fakeOpLogin{id: "expired", userID: "a1", email: "[email protected]", status: "approved", expiresAt: past, createdAt: past}
repo.opLogins["consumed"] = &fakeOpLogin{id: "consumed", userID: "a1", email: "[email protected]", status: "approved", consumed: true, expiresAt: future, createdAt: future}
repo.opLogins["denied"] = &fakeOpLogin{id: "denied", userID: "a1", email: "[email protected]", status: "denied", expiresAt: future, createdAt: future}
repo.opLogins["live"] = &fakeOpLogin{id: "live", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: future}
for _, id := range []string{"unknown-handle", "pending", "expired", "consumed", "denied"} {
w := statusOp(eh, id)
if w.Code != http.StatusOK {
t.Fatalf("status %q: code = %d, want 200", id, w.Code)
}
if acctBody(t, w)["approved"] != false {
t.Errorf("status %q: approved = true, want false (must not be an oracle)", id)
}
}
// Only the genuinely-approved live request reads true.
if acctBody(t, statusOp(eh, "live"))["approved"] != true {
t.Error("status of an approved live request must read approved:true")
}
}
// TestOpLoginFinishUniform is the redeem-side failure matrix. The anchor is uniformity:
// an unknown handle and a wrong code for an approved request answer with the SAME
// (code, message) envelope, so finish never doubles as an oracle. Two lifecycle
// invariants are pinned alongside: a correct code submitted BEFORE approval is
// preserved (approval read before consume), and a wrong code costs an attempt without
// burning the approval.
func TestOpLoginFinishUniform(t *testing.T) {
t.Run("unknown handle and wrong code are indistinguishable", func(t *testing.T) {
api, _, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code for a real, approved request.
wWrong := finishOp(eh, reqID, code+"x")
// Unknown handle.
wGhost := finishOp(eh, "deadbeefdeadbeefdeadbeefdeadbeef", code)
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
}
wc, wm := errEnvelope(t, wWrong)
gc, gm := errEnvelope(t, wGhost)
if wc != "op_login_invalid" || wc != gc || wm != gm {
t.Errorf("envelopes differ: wrong=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
}
})
t.Run("correct code before approval is preserved, not burned", func(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
// Finish before approval: refused, and the code is NOT consumed.
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("early finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
for _, o := range repo.otps {
if o.consumed || o.attempts != 0 {
t.Errorf("early finish must not touch the code: consumed=%v attempts=%d", o.consumed, o.attempts)
}
}
// Approve, then the same code completes.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
t.Fatalf("finish with preserved code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
t.Run("wrong code charges an attempt without burning the approval", func(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code: refused, one attempt charged, request still approved+unconsumed.
if w := finishOp(eh, reqID, code+"x"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("wrong code: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
for _, o := range repo.otps {
if o.consumed || o.attempts != 1 {
t.Errorf("wrong code must charge one attempt, not consume: consumed=%v attempts=%d", o.consumed, o.attempts)
}
}
if r := repo.opLogins[reqID]; r.status != "approved" || r.consumed {
t.Errorf("a wrong code must not burn the approval: status=%q consumed=%v", r.status, r.consumed)
}
// The right code still completes.
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
t.Fatalf("retry with right code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
}
// TestOpLoginApproveGate pins the in-game approval gate: only a linked role=admin UUID
// may vouch (all refusals share one 403 not_admin), a missing/no-longer-pending request
// is 404, and a bare request without an approver UUID is 400.
func TestOpLoginApproveGate(t *testing.T) {
plantPending := func(repo *fakeRepo) string {
repo.opLogins["r1"] = &fakeOpLogin{
id: "r1", userID: "a1", email: "[email protected]", status: "pending",
expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_000, 0),
}
return "r1"
}
t.Run("unlinked approver UUID -> 403, request stays pending", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, "ffffffff-ffff-ffff-ffff-ffffffffffff"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("unlinked approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
if repo.opLogins[id].status != "pending" {
t.Error("a refused approval must leave the request pending")
}
})
t.Run("linked non-admin approver -> 403", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
if w := approveOp(api.InternalHandler(), id, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("non-admin approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
})
t.Run("missing approver_uuid -> 400", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := do(api.InternalHandler(), "POST", "/api/v1/internal/op-login/"+id+"/approve", `{}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("missing approver_uuid: code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
t.Run("unknown request id -> 404", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
if w := approveOp(api.InternalHandler(), "nosuchrequest", opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
t.Fatalf("unknown request: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
}
})
t.Run("re-approving an approved request -> 404 (first approval stands)", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusOK {
t.Fatalf("first approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusNotFound {
t.Fatalf("second approve: code = %d, want 404 (no longer pending)", w.Code)
}
if repo.opLogins[id].status != "approved" {
t.Error("the request must remain approved after a redundant re-approval")
}
})
}
// TestOpLoginPendingList covers the internal push list: live pending requests are
// returned oldest first, carrying the joined username, and an approved or expired
// request is absent.
func TestOpLoginPendingList(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
ih := api.InternalHandler()
future := time.Unix(1_700_000_600, 0)
// Two pending (distinct createdAt so ordering is deterministic), one approved, one
// expired.
repo.opLogins["r2"] = &fakeOpLogin{id: "r2", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)}
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)}
repo.opLogins["ap"] = &fakeOpLogin{id: "ap", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)}
repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "[email protected]", status: "pending", expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_700_000_050, 0)}
w := do(ih, "GET", "/api/v1/internal/op-login/pending", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("pending: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
body := acctBody(t, w)
pending, _ := body["pending"].([]any)
if len(pending) != 2 {
t.Fatalf("pending count = %d, want 2 (only live pending rows) — %v", len(pending), body["pending"])
}
// Oldest first: r1 (created earlier) before r2.
first, _ := pending[0].(map[string]any)
second, _ := pending[1].(map[string]any)
if first["request_id"] != "r1" || second["request_id"] != "r2" {
t.Errorf("order = [%v, %v], want [r1, r2] (oldest first)", first["request_id"], second["request_id"])
}
if first["username"] != "op" || first["email"] != "[email protected]" {
t.Errorf("row projection = %v, want username op / email [email protected]", first)
}
}
// TestOpLoginGates covers the shared front doors: the fail-closed local-auth toggle on
// all three public legs, the CSRF Content-Type guard on the credential-minting POSTs,
// and the input gates that must reject before any lookup or mint.
func TestOpLoginGates(t *testing.T) {
t.Run("local auth disabled -> 403 on start/status/finish", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
eh := api.ExternalHandler()
if w := startOp(eh, "[email protected]"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := statusOp(eh, "anything"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("status: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := finishOp(eh, "anything", "123456"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on the minting POSTs", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("start Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("start bad email -> 400, nothing minted", func(t *testing.T) {
for _, body := range []string{`{}`, `{"email":""}`, `{"email":"notanemail"}`, `{"email":"[email protected]","x":1}`} {
api, repo, mailer := seedOpLoginAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/op-login/start", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("start %q: code = %d, want 400 (%s)", body, w.Code, w.Body.String())
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 {
t.Errorf("start %q: a rejected start must mint nothing", body)
}
}
})
t.Run("finish missing request_id or code -> 400 bad_request", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
for _, body := range []string{`{"code":"123456"}`, `{"request_id":"x"}`, `{"request_id":"","code":""}`} {
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", body, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Errorf("finish %q: code = %d body %s, want 400 bad_request", body, w.Code, w.Body.String())
}
}
})
}
// TestOpLoginFaceSeparation enforces the two-face split: the three public browser legs
// must 404 on the internal (service-token) face, and the two internal in-game legs must
// 404 on the external (Access-JWT) face.
func TestOpLoginFaceSeparation(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
// Public legs must not appear on the internal face.
if w := do(ih, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("start on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "GET", "/api/v1/auth/op-login/status/x", "", nil); w.Code != http.StatusNotFound {
t.Errorf("status on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
// Internal legs must not appear on the external face.
if w := do(eh, "GET", "/api/v1/internal/op-login/pending", "", nil); w.Code != http.StatusNotFound {
t.Errorf("pending on external face: code = %d, want 404", w.Code)
}
if w := do(eh, "POST", "/api/v1/internal/op-login/x/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("approve on external face: code = %d, want 404", w.Code)
}
}