Files
Felis/internal/api/handlers_auth_options_test.go
T
flyemoji 0c1cc598c1 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 ./...).
2026-07-04 21:47:12 +09:00

191 lines
8.4 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// Pre-session identifier-first discovery tests (spec §B, #71). Load-bearing properties:
//
// - Methods reflect real state: email_otp for any resolved verified account, plus
// passkey when a verifier is wired AND the account has >=1 enrolled credential.
// - Existence IS disclosed: an unknown address returns an empty methods array. This
// endpoint is the deliberate, sanctioned counter-slice to the anti-enumeration
// login doors, so it does not disguise non-existence.
// - Staffness is NOT disclosed: a staff email and a player email in the same
// credential state return BYTE-IDENTICAL bodies — the highest-value guard, because a
// role branch here would out which addresses are operators.
// - passkey is gated on a wired verifier: options never advertises a method the finish
// door would immediately 503.
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
// (u1) and a verified staff account (a1), and a passkey verifier wired by default.
// Callers seed passkey credentials per-test to set the credential state.
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{}
return api, repo
}
const authOptionsPath = "/api/v1/auth/options"
// optionsMethods pulls the methods array out of a 200 body as []string.
func optionsMethods(t *testing.T, w *httptest.ResponseRecorder) []string {
t.Helper()
raw, ok := acctBody(t, w)["methods"].([]any)
if !ok {
t.Fatalf("body has no methods array: %s", w.Body.String())
}
out := make([]string, len(raw))
for i, m := range raw {
out[i], _ = m.(string)
}
return out
}
func TestAuthOptionsMethodsByState(t *testing.T) {
t.Run("account with no passkey -> email_otp only", func(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
t.Errorf("methods = %v, want [email_otp]", got)
}
})
t.Run("account with a passkey (verifier wired) -> passkey + email_otp", func(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Deterministic order (passkey before email_otp) so clients and this assertion
// can compare without sorting.
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"passkey", "email_otp"}) {
t.Errorf("methods = %v, want [passkey email_otp]", got)
}
})
}
// TestAuthOptionsUnknownEmail pins the sanctioned-oracle contract: an address with no
// verified account is not disguised — it returns an explicit empty array (not null), so
// the client can trust "no methods" as "no account".
func TestAuthOptionsUnknownEmail(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); len(got) != 0 {
t.Errorf("methods = %v, want []", got)
}
if body := w.Body.String(); !strings.Contains(body, `"methods":[]`) {
t.Errorf("unknown-email body = %s, want an explicit \"methods\":[] (not null)", body)
}
}
// TestAuthOptionsDoesNotRevealStaffness is the security anchor. For each credential
// state, a staff address and a player address in the SAME state must return
// byte-identical bodies. A role branch in the handler — even one that only reordered or
// relabelled — would out which addresses are operators; this is the guard that such a
// branch can never be introduced without a red test.
func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) {
states := []struct {
name string
withPasskey bool
}{
{"neither has a passkey", false},
{"both have a passkey", true},
}
for _, st := range states {
t.Run(st.name, func(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
if st.withPasskey {
repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-u1", PublicKey: "k", CreatedAt: frozenNow}
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow}
}
eh := api.ExternalHandler()
wPlayer := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
wStaff := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if wPlayer.Code != http.StatusOK || wStaff.Code != http.StatusOK {
t.Fatalf("codes = %d/%d, want 200/200", wPlayer.Code, wStaff.Code)
}
if wPlayer.Body.String() != wStaff.Body.String() {
t.Errorf("staff/player bodies differ — options reveals staffness:\n player: %s\n staff: %s",
wPlayer.Body.String(), wStaff.Body.String())
}
})
}
}
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
// that state, so options must NOT advertise passkey — it would be a dead offer.
func TestAuthOptionsPasskeyRequiresWiredVerifier(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
api.Passkey = nil
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
t.Errorf("methods = %v, want [email_otp] (passkey must not be offered without a wired verifier)", got)
}
}
func TestAuthOptionsGates(t *testing.T) {
t.Run("local auth disabled -> 403", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415", func(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("bad or unknown-field body -> 400", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
api, _ := seedAuthOptionsAPI(t)
eh := api.ExternalHandler()
for name, body := range bad {
if w := do(eh, "POST", authOptionsPath, body, jsonHeader); w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
}
})
}
// TestAuthOptionsFaceSeparation: the route is external-only (registered in
// externalAPIRoutes), so the internal face must 404 it.
func TestAuthOptionsFaceSeparation(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("options on internal face: code = %d, want 404 (it is external-only)", w.Code)
}
}