Files
Felis/internal/api/handlers_setup_lockdown_test.go
T

172 lines
7.8 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// TestAdvClaim403MechanismIsRequireOnboarded independently verifies the claimed
// root cause of the live claim-403 report. It asserts three things the claim
// implies, each of which would falsify the claim if it failed:
//
// 1. /me/servers (SetupAllowed) returns 200 for a bind-onboarded player, which is
// why the dashboard renders demo2 with a 认领 button at all.
// 2. claim, wake AND status all return 403 with code "setup_required" — i.e. the
// 403 comes from requireOnboarded (api.go:612-625) BEFORE the handler, not
// from isOwnerOrAdmin inside it (that path returns code "forbidden").
// 3. Flipping exactly ONE bit — enrolling a passkey — lifts the 403 on all three.
// This isolates requireOnboarded as the sole cause: nothing about the server
// record, ownership, or quota changed between the failing and passing runs.
func TestAdvClaim403MechanismIsRequireOnboarded(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "DEMO2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"DEMO2345"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("bind = %d, want 200 (%s)", w.Code, w.Body.String())
}
cookie := map[string]string{"Cookie": sessionCookieName + "=" + sessionCookieValue(t, w)}
userID := repo.staff[bindTestUUID].ID
// Sanity: the bind-created player really is the lockdown-eligible state —
// unverified email (no email at all) and no passkey.
if u := repo.staff[bindTestUUID]; u.EmailVerified {
t.Fatalf("bind-created player has EmailVerified=true; lockdown premise is wrong")
}
if creds, _ := repo.PasskeyCredentialsForUser(t.Context(), userID); len(creds) != 0 {
t.Fatalf("bind-created player already has %d passkeys; lockdown premise is wrong", len(creds))
}
// demo2 exists, is unclaimed, and the player is linked + within quota. Every
// precondition for a SUCCESSFUL claim is satisfied, so any 403 here is the
// middleware, not the handler's own authorization.
cl := api.Cluster.(*fakeCluster)
cl.byName["demo2"] = &ServerInfo{Name: "demo2", Subdomain: "demo2", Phase: "Stopped"}
repo.claimOK["demo2"] = true
repo.quota[userID] = true
if got := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", cookie); got.Code != http.StatusOK {
t.Fatalf("/me/servers = %d, want 200 (%s)", got.Code, got.Body.String())
}
routes := []struct{ method, path string }{
{"POST", "/api/v1/servers/demo2/claim"},
{"POST", "/api/v1/servers/demo2/wake"},
{"GET", "/api/v1/servers/demo2/status"},
}
for _, tc := range routes {
got := do(api.ExternalHandler(), tc.method, tc.path, "", cookie)
if got.Code != http.StatusForbidden {
t.Fatalf("%s %s = %d, want 403 (%s)", tc.method, tc.path, got.Code, got.Body.String())
}
// The DISCRIMINATOR: setup_required proves requireOnboarded fired. If the
// 403 came from isOwnerOrAdmin the code would be "forbidden".
if c := errCode(got.Body.Bytes()); c != "setup_required" {
t.Fatalf("%s %s 403 code = %q, want setup_required (%s)",
tc.method, tc.path, c, got.Body.String())
}
}
// One bit: enroll a passkey. Nothing else about the request changes.
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: userID, CredentialID: "c1"}
// Claim now reaches its handler and succeeds.
if got := do(api.ExternalHandler(), "POST", "/api/v1/servers/demo2/claim", "", cookie); got.Code != http.StatusOK {
t.Fatalf("post-passkey claim = %d %q, want 200 (%s)", got.Code, errCode(got.Body.Bytes()), got.Body.String())
}
// fakeRepo.ClaimServer reports success without writing ownership (the fake models
// the UPDATE's row count only), so mirror the PG write the real claim performs:
// UPDATE servers SET owner_id=$user WHERE name=$name AND owner_id IS NULL.
repo.byName["demo2"] = &ServerRecord{Name: "demo2", Subdomain: "demo2", OwnerID: userID}
// With ownership recorded, wake and status pass authorizeWake/isOwnerOrAdmin —
// proving the earlier 403s were the lockdown, not the ownership check.
for _, tc := range routes[1:] {
got := do(api.ExternalHandler(), tc.method, tc.path, "", cookie)
if got.Code == http.StatusForbidden {
t.Fatalf("%s %s still 403 for the owner after passkey enrollment (%s)",
tc.method, tc.path, got.Body.String())
}
}
}
// TestAdvRequireOnboardedPredicate characterizes the ACTUAL 403-producing predicate
// (api.go:615) directly, bypassing fakeRepo.SessionUser — which, unlike the real
// PGRepo.SessionUser (pgrepo.go:927, it selects email_verified), never populates
// EmailVerified and so cannot express a verified-email session.
//
// This is the falsification test for the claim's wording: the lockdown is NOT
// unconditional on non-SetupAllowed routes. Three of the five states below sail
// straight through, so "the route is not in the exemption list" does not by itself
// yield 403 — the conjunction ViaSession && !EmailVerified && no-passkey does.
func TestAdvRequireOnboardedPredicate(t *testing.T) {
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
repo.passkeyCreds["pk-enrolled"] = PasskeyCredential{ID: "pk-enrolled", UserID: "has-pk", CredentialID: "c1"}
const passed = http.StatusTeapot // the wrapped handler ran
h := api.requireOnboarded(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(passed) })
for _, tc := range []struct {
name string
p *Principal
want int
}{
// The live demo2 player: bind-onboarded, no email, no passkey.
{"session player, unverified, no passkey", &Principal{UserID: "u1", ViaSession: true, EmailVerified: false}, http.StatusForbidden},
{"session player, email verified", &Principal{UserID: "u1", ViaSession: true, EmailVerified: true}, passed},
{"session player, unverified but passkey enrolled", &Principal{UserID: "has-pk", ViaSession: true, EmailVerified: false}, passed},
// Admin Zero-Trust path carries no session flag; internal face carries no principal.
{"non-session principal", &Principal{UserID: "a1", ViaSession: false, EmailVerified: false}, passed},
{"nil principal (internal face)", nil, passed},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api/v1/servers/demo2/wake", nil)
if tc.p != nil {
r = r.WithContext(context.WithValue(r.Context(), ctxKeyPrincipal, tc.p))
}
w := httptest.NewRecorder()
h(w, r)
if w.Code != tc.want {
t.Fatalf("code = %d, want %d (%s)", w.Code, tc.want, w.Body.String())
}
if tc.want == http.StatusForbidden && errCode(w.Body.Bytes()) != "setup_required" {
t.Fatalf("403 code = %q, want setup_required", errCode(w.Body.Bytes()))
}
})
}
}
// credsDown is a store whose passkey lookup fails, as during a Postgres outage.
type credsDown struct{ *fakeRepo }
func (credsDown) PasskeyCredentialsForUser(context.Context, string) ([]PasskeyCredential, error) {
return nil, errors.New("connection refused")
}
// A failed passkey lookup is an outage: the caller gets 503 auth_unavailable to
// retry, never setup_required, which would send a player with a passkey off to
// enroll another.
func TestRequireOnboardedReportsAStoreOutage(t *testing.T) {
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.Repo = credsDown{repo}
ran := false
h := api.requireOnboarded(func(http.ResponseWriter, *http.Request) { ran = true })
r := httptest.NewRequest("POST", "/api/v1/servers/demo2/wake", nil)
r = r.WithContext(context.WithValue(r.Context(), ctxKeyPrincipal,
&Principal{UserID: "has-pk", ViaSession: true, EmailVerified: false}))
w := httptest.NewRecorder()
h(w, r)
if ran {
t.Fatal("the handler ran although the onboarding check could not be made")
}
if w.Code != http.StatusServiceUnavailable || errCode(w.Body.Bytes()) != "auth_unavailable" {
t.Fatalf("got %d %s, want 503 auth_unavailable", w.Code, w.Body.String())
}
}