Files
Felis/internal/api/handlers_setup_test.go
T

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, ReauthAt: frozenNow}}
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"]
}