Three tests carried the maintainer's production root domain, a personal mailbox and the public IP of a live demo host as fixture values. None of them needs the value to be real: the re-domain test only needs two different roots, and the setup flow only needs a well-formed address. Swap them for the placeholders the rest of the suite already uses (mc.example.net, [email protected]), and move the "before" root in the re-domain test to 203.0.113.10.nip.io. That address is from the RFC 5737 documentation range, so the stale install the test models still has an IP-derived hostname, which is the case the refresh exists for.
149 lines
6.7 KiB
Go
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"]
|
|
}
|