822 lines
38 KiB
Go
822 lines
38 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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 API's own
|
|
// user table is the sole gate (velocity's command itself is unprivileged).
|
|
|
|
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, username string) *httptest.ResponseRecorder {
|
|
return do(ih, "POST", "/api/v1/internal/op-login/"+id+"/approve",
|
|
`{"approver_uuid":"`+approverUUID+`","username":"`+username+`"}`, 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, "op"); 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)
|
|
}
|
|
|
|
// Four audits by "op": otp_sent (start), the early finish refused before
|
|
// approval, approved (in-game vouch), op_login (finish).
|
|
if n := len(repo.audits); n != 4 {
|
|
t.Fatalf("want 4 audits, got %d: %+v", n, repo.audits)
|
|
}
|
|
wantActions := []string{"auth.op_login.otp_sent", "auth.op_login.failed", "auth.op_login.approved", "auth.op_login"}
|
|
for i, want := range wantActions {
|
|
if repo.audits[i].Action != want || repo.audits[i].Actor != "op" || repo.audits[i].ActorUserID != "a1" {
|
|
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"])
|
|
}
|
|
}
|
|
|
|
// TestOpLoginOwnerAdmitted pins that the staff door admits the Owner (role=owner),
|
|
// not just plain admins: the Owner is the primary op.console identity, so a
|
|
// role check of "admin only" would strand it outside its own console.
|
|
func TestOpLoginOwnerAdmitted(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
|
repo.staff["owner"] = &StaffUser{
|
|
ID: "o1", Username: "owner", Email: "[email protected]",
|
|
Role: "owner", EmailVerified: true,
|
|
}
|
|
repo.links[opUUID] = "o1"
|
|
mailer := &captureMailer{}
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
api.Mailer = mailer
|
|
eh := api.ExternalHandler()
|
|
ih := api.InternalHandler()
|
|
|
|
w := startOp(eh, "[email protected]")
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("owner start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
|
}
|
|
reqID, _ := acctBody(t, w)["request_id"].(string)
|
|
if reqID == "" || mailer.calls != 1 || len(repo.opLogins) != 1 {
|
|
t.Fatalf("owner start must mint a request + mail a code: req=%q mails=%d rows=%d",
|
|
reqID, mailer.calls, len(repo.opLogins))
|
|
}
|
|
if w := approveOp(ih, reqID, opUUID, "owner"); w.Code != http.StatusOK {
|
|
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
w = finishOp(eh, reqID, mailer.code)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("owner finish: code = %d body %s, want 200", w.Code, w.Body.String())
|
|
}
|
|
if vb := acctBody(t, w); vb["role"] != "owner" {
|
|
t.Fatalf("finish role = %v, want owner", vb["role"])
|
|
}
|
|
}
|
|
|
|
// 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 and mint/mail nothing, and a re-probe inside the cooldown
|
|
// does the same — 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, wantReason, wantUser 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 {
|
|
t.Errorf("neutral start must mint/mail nothing: reqs=%d otps=%d mails=%d",
|
|
len(repo.opLogins), len(repo.otps), mailer.calls)
|
|
}
|
|
// The response is neutral; the operator's record is not.
|
|
if len(repo.audits) != 1 || repo.audits[0].Action != "auth.op_login.failed" ||
|
|
!strings.Contains(string(repo.audits[0].Payload), `"reason":"`+wantReason+`"`) ||
|
|
repo.audits[0].ActorUserID != wantUser {
|
|
t.Errorf("neutral start audits = %+v, want one auth.op_login.failed %s by %q", repo.audits, wantReason, wantUser)
|
|
}
|
|
// Re-probing inside the window: the same 202 shape, still nothing behind it.
|
|
w = startOp(eh, email)
|
|
if b := acctBody(t, w); w.Code != http.StatusAccepted || b["request_id"] == "" || b["expires_at"] != "2023-11-14T22:23:20Z" {
|
|
t.Fatalf("re-probe: code = %d body %s, want 202 with a request_id and the first expiry", w.Code, w.Body.String())
|
|
}
|
|
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 {
|
|
t.Errorf("re-probe must mint/mail nothing: reqs=%d otps=%d mails=%d",
|
|
len(repo.opLogins), len(repo.otps), mailer.calls)
|
|
}
|
|
}
|
|
|
|
t.Run("unknown address", func(t *testing.T) {
|
|
check(t, nil, "[email protected]", "no_account", "")
|
|
})
|
|
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]", "not_staff", "u9")
|
|
})
|
|
}
|
|
|
|
// TestOpLoginStartByAStranger is the case of someone who knows a staff address and
|
|
// keeps starting logins for it. Their start mails the code to the staff inbox; the
|
|
// staff member's own start inside the cooldown still gets a request of its own
|
|
// (nothing new mailed, same expiry as the live code), and that inbox code finishes
|
|
// it. A start after the cooldown mails a second code without cancelling the first.
|
|
func TestOpLoginStartByAStranger(t *testing.T) {
|
|
api, repo, mailer := seedOpLoginAPI(t)
|
|
clock := time.Unix(1_700_000_000, 0)
|
|
api.Now = func() time.Time { return clock }
|
|
eh := api.ExternalHandler()
|
|
ih := api.InternalHandler()
|
|
requestID := func(w *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
|
}
|
|
id, _ := acctBody(t, w)["request_id"].(string)
|
|
return id
|
|
}
|
|
|
|
strangers := requestID(startOp(eh, "[email protected]"))
|
|
inboxCode := mailer.code
|
|
clock = clock.Add(30 * time.Second)
|
|
w := startOp(eh, "[email protected]")
|
|
own := requestID(w)
|
|
if own == strangers || repo.opLogins[own] == nil {
|
|
t.Fatalf("own request %q must be a new, stored request beside the stranger's %q", own, strangers)
|
|
}
|
|
if b := acctBody(t, w); b["expires_at"] != "2023-11-14T22:23:20Z" {
|
|
t.Errorf("own start expires_at = %v, want 2023-11-14T22:23:20Z (the live code's)", b["expires_at"])
|
|
}
|
|
if mailer.calls != 1 || len(repo.otps) != 1 {
|
|
t.Fatalf("start inside the cooldown: mails=%d otps=%d, want 1/1", mailer.calls, len(repo.otps))
|
|
}
|
|
if w := approveOp(ih, own, opUUID, "op"); w.Code != http.StatusOK {
|
|
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if w := finishOp(eh, own, inboxCode); w.Code != http.StatusOK {
|
|
t.Fatalf("finish own request with the inbox code: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// After the cooldown a start mails a second code; the first one still works.
|
|
clock = clock.Add(otpResendCooldown)
|
|
first := requestID(startOp(eh, "[email protected]"))
|
|
firstCode := mailer.code
|
|
clock = clock.Add(otpResendCooldown)
|
|
requestID(startOp(eh, "[email protected]"))
|
|
if mailer.calls != 3 {
|
|
t.Fatalf("mails = %d, want 3", mailer.calls)
|
|
}
|
|
if w := approveOp(ih, first, opUUID, "op"); w.Code != http.StatusOK {
|
|
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if w := finishOp(eh, first, firstCode); w.Code != http.StatusOK {
|
|
t.Fatalf("finish with the earlier code after a newer start: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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, "op"); 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, "op"); 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, "op"); 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())
|
|
}
|
|
})
|
|
}
|
|
|
|
// An account an admin retires between the approval and the finish signs in
|
|
// nowhere: finish answers the uniform failure, sets no cookie, stores no session,
|
|
// and leaves the code as it was.
|
|
func TestOpLoginFinishRefusesRetiredAccount(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
retire func(repo *fakeRepo)
|
|
}{
|
|
{"disabled", func(repo *fakeRepo) {
|
|
u := UserView{ID: "a1", Username: "op", Email: "[email protected]", Role: "admin", Disabled: true}
|
|
repo.seededUsers = append(repo.seededUsers, seededUser{view: u, detail: UserDetail{UserView: u}})
|
|
}},
|
|
{"deleted", func(repo *fakeRepo) { repo.deletedIDs["a1"] = true }},
|
|
} {
|
|
t.Run(tc.name, 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)
|
|
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
|
|
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
tc.retire(repo)
|
|
|
|
w := finishOp(eh, reqID, mailer.code)
|
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" || len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("finish: code = %d body %s cookies %v, want 400 op_login_invalid and none",
|
|
w.Code, w.Body.String(), w.Result().Cookies())
|
|
}
|
|
if len(repo.sessions) != 0 {
|
|
t.Fatalf("sessions = %d, want none", len(repo.sessions))
|
|
}
|
|
for _, o := range repo.otps {
|
|
if o.consumed || o.attempts != 0 {
|
|
t.Errorf("the code must stay untouched: consumed=%v attempts=%d", o.consumed, o.attempts)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// A store that cannot say whether the account is live is an outage: the finish
|
|
// can be retried with the same approval, so it is not told to start over.
|
|
t.Run("store outage", 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)
|
|
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
|
|
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
repo.failUserDetail = errors.New("connection refused")
|
|
if w := finishOp(eh, reqID, mailer.code); w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("finish: code = %d body %s, want 500", w.Code, w.Body.String())
|
|
}
|
|
repo.failUserDetail = nil
|
|
if w := finishOp(eh, reqID, mailer.code); w.Code != http.StatusOK {
|
|
t.Fatalf("retry: code = %d body %s, want 200", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOpLoginApproveGate pins the in-game approval gate: only a linked staff UUID
|
|
// (role admin or owner) 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", "op"); 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", "op"); 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("a linked owner-role approver vouches too", func(t *testing.T) {
|
|
// The owner role is a superset of admin (auth.go staffRole), so a manually
|
|
// promoted Owner (migration 0011) must pass the in-game approve gate.
|
|
api, repo, _ := seedOpLoginAPI(t)
|
|
repo.staff["boss"] = &StaffUser{ID: "b1", Username: "boss", Role: "owner"}
|
|
repo.links["cccccccc-cccc-cccc-cccc-cccccccccccc"] = "b1"
|
|
id := plantPending(repo)
|
|
if w := approveOp(api.InternalHandler(), id, "cccccccc-cccc-cccc-cccc-cccccccccccc", "op"); w.Code != http.StatusOK {
|
|
t.Fatalf("owner-role approver: code = %d body %s, want 200", 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, "op"); 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, "op"); 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, "op"); 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), clientIP: "198.51.100.7"}
|
|
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]" || first["client_ip"] != "198.51.100.7" {
|
|
t.Errorf("row projection = %v, want username op / email [email protected] / client_ip 198.51.100.7", 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 (session) 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+`","username":"op"}`, nil); w.Code != http.StatusNotFound {
|
|
t.Errorf("approve on external face: code = %d, want 404", w.Code)
|
|
}
|
|
if w := do(eh, "GET", "/api/v1/internal/op-login/x?approver_uuid="+opUUID, "", nil); w.Code != http.StatusNotFound {
|
|
t.Errorf("show on external face: code = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// showOp drives the in-game "who is this for" read.
|
|
func showOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
|
|
return do(ih, "GET", "/api/v1/internal/op-login/"+id+"?approver_uuid="+approverUUID, "", nil)
|
|
}
|
|
|
|
// TestOpLoginShowsWhoIsWaiting pins what the in-game admin sees before vouching:
|
|
// start records where the sign-in came from, and the show read returns the account,
|
|
// its address and that origin to a linked staff approver only.
|
|
func TestOpLoginShowsWhoIsWaiting(t *testing.T) {
|
|
api, repo, _ := seedOpLoginAPI(t)
|
|
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
|
|
|
w := do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`,
|
|
map[string]string{"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0"})
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("start: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
reqID, _ := acctBody(t, w)["request_id"].(string)
|
|
row := repo.opLogins[reqID]
|
|
if row == nil || row.clientIP != "192.0.2.1" || row.userAgent != "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0" {
|
|
t.Fatalf("stored origin = %+v, want client 192.0.2.1 and the Firefox user agent", row)
|
|
}
|
|
|
|
w = showOp(ih, reqID, opUUID)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("show: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
want := map[string]any{
|
|
"request_id": reqID,
|
|
"username": "op",
|
|
"email": "[email protected]",
|
|
"client_ip": "192.0.2.1",
|
|
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0",
|
|
"expires_at": "2023-11-14T22:23:20Z",
|
|
}
|
|
got := acctBody(t, w)
|
|
for k, v := range want {
|
|
if got[k] != v {
|
|
t.Errorf("show %s = %v, want %v", k, got[k], v)
|
|
}
|
|
}
|
|
if _, ok := got["created_at"].(string); !ok {
|
|
t.Errorf("show must carry created_at, got %v", got)
|
|
}
|
|
|
|
t.Run("refusals", func(t *testing.T) {
|
|
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
|
|
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
|
|
repo.opLogins["old"] = &fakeOpLogin{id: "old", userID: "a1", email: "[email protected]", status: "pending",
|
|
expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_699_999_400, 0)}
|
|
for _, tc := range []struct {
|
|
name, target string
|
|
code int
|
|
errCode string
|
|
}{
|
|
{"no approver", "/api/v1/internal/op-login/" + reqID, http.StatusBadRequest, "bad_request"},
|
|
{"unlinked approver", "/api/v1/internal/op-login/" + reqID + "?approver_uuid=ffffffff-ffff-ffff-ffff-ffffffffffff", http.StatusForbidden, "not_admin"},
|
|
{"linked player", "/api/v1/internal/op-login/" + reqID + "?approver_uuid=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", http.StatusForbidden, "not_admin"},
|
|
{"unknown request", "/api/v1/internal/op-login/nosuchrequest?approver_uuid=" + opUUID, http.StatusNotFound, "op_login_not_found"},
|
|
{"expired request", "/api/v1/internal/op-login/old?approver_uuid=" + opUUID, http.StatusNotFound, "op_login_not_found"},
|
|
} {
|
|
w := do(ih, "GET", tc.target, "", nil)
|
|
if w.Code != tc.code || decodeErr(t, w) != tc.errCode {
|
|
t.Errorf("%s: code = %d body %s, want %d %s", tc.name, w.Code, w.Body.String(), tc.code, tc.errCode)
|
|
}
|
|
if strings.Contains(w.Body.String(), "[email protected]") {
|
|
t.Errorf("%s: a refusal leaked the staff address: %s", tc.name, w.Body.String())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an approved request is no longer shown", func(t *testing.T) {
|
|
if w := approveOp(ih, reqID, opUUID, "op"); w.Code != http.StatusOK {
|
|
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if w := showOp(ih, reqID, opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
|
|
t.Fatalf("show after approval: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOpLoginApproveNamesTheAccount pins the confirmation: the admin must type the
|
|
// name of the account the request is for. A different name leaves the request
|
|
// pending and is audited; the matching name (any case) approves, and the response
|
|
// says whose sign-in was approved.
|
|
func TestOpLoginApproveNamesTheAccount(t *testing.T) {
|
|
api, repo, _ := seedOpLoginAPI(t)
|
|
ih := api.InternalHandler()
|
|
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_699_999_900, 0), clientIP: "203.0.113.50"}
|
|
|
|
if w := do(ih, "POST", "/api/v1/internal/op-login/r1/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
|
t.Fatalf("no username: code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
|
|
}
|
|
|
|
w := approveOp(ih, "r1", opUUID, "alice")
|
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "op_login_mismatch" {
|
|
t.Fatalf("wrong name: code = %d body %s, want 409 op_login_mismatch", w.Code, w.Body.String())
|
|
}
|
|
if repo.opLogins["r1"].status != "pending" {
|
|
t.Fatal("a mismatched approval must leave the request pending")
|
|
}
|
|
if n := len(repo.audits); n != 1 {
|
|
t.Fatalf("audits after mismatch = %d, want 1: %+v", n, repo.audits)
|
|
}
|
|
if a := repo.audits[0]; a.Action != "auth.op_login.approve_mismatch" || a.ActorUserID != "a1" ||
|
|
string(a.Payload) != `{"request_id":"r1","typed_username":"alice"}` {
|
|
t.Errorf("mismatch audit = %+v payload %s", a, a.Payload)
|
|
}
|
|
|
|
w = approveOp(ih, "r1", opUUID, "OP")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("matching name: code = %d body %s, want 200", w.Code, w.Body.String())
|
|
}
|
|
body := acctBody(t, w)
|
|
if body["approved"] != true || body["username"] != "op" || body["email"] != "[email protected]" {
|
|
t.Errorf("approve body = %v, want approved:true username:op email:[email protected]", body)
|
|
}
|
|
if repo.opLogins["r1"].status != "approved" {
|
|
t.Error("the matching name must approve the request")
|
|
}
|
|
if a := repo.audits[len(repo.audits)-1]; a.Action != "auth.op_login.approved" ||
|
|
string(a.Payload) != `{"approver_user_id":"a1","client_ip":"203.0.113.50","request_id":"r1","username":"op"}` {
|
|
t.Errorf("approved audit = %+v payload %s", a, a.Payload)
|
|
}
|
|
}
|