172 lines
7.8 KiB
Go
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())
|
|
}
|
|
}
|