At bootstrap there is no SMTP, so the old /setup flow was unreachable: it requested an emailed OTP that could never arrive. Setup now records the Owner's email address unverified (no OTP round-trip) and requires a passkey, deferring SMTP configuration to a later Settings page. Setup completes on email-recorded + passkey-enrolled, and the lockdown lifts on the passkey, not on email_verified: a passkey is the Owner's only pre-SMTP login credential (email-OTP login refuses admin accounts). The record-email endpoint (POST /account/email) now clears email_verified in the same write. Only VerifyEmailOTP, which proves control of the address, may set that flag; recording a fresh unproven address must never leave a stale email_verified=true asserting a proof the user never gave. The change strictly tightens the invariant, so no existing reader breaks. Remove the dead ErrEmailTaken path and its documented 409: no migration puts a unique index on users.email and the codebase does not enforce email uniqueness, so the unique-violation branch was unreachable and the 409 an impossible response. The /setup route (Setup.tsx, setEmail helper, setup i18n copy) is rewritten to match: record-email, mandatory passkey, no skip-for-now. The SMTP settings page and post-setup configure-SMTP nudge are deferred.
107 lines
4.8 KiB
Go
107 lines
4.8 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)
|
|
}
|
|
}
|
|
|
|
// 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"]
|
|
}
|