A wake refused by the §9.1 running-server cap returns 503, but the per-server cooldown was recorded before the cap check ran. A player held because the cluster was momentarily full would then also have to wait out the wake cooldown once a slot freed, even though their refused wake never actually flipped desiredState. Split cooldownLimiter.allow into allowed (peek, no record) and record (commit). Both wake paths now consult allowed for the 429, then call record only after SetDesiredState succeeds — so neither a 503 at_capacity nor a SetDesiredState error consumes the cooldown. The split is safe against the running cap, which counts CRD truth via ListServers and is independent of the limiter.
1180 lines
43 KiB
Go
1180 lines
43 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
const testRoot = "mc.example.net" // neutral; never a deployment domain
|
|
|
|
// ---- fakes ----
|
|
|
|
type fakeRepo struct {
|
|
bySub map[string]*ServerRecord
|
|
byName map[string]*ServerRecord
|
|
linked map[string]bool
|
|
quota map[string]bool
|
|
allowlist map[string]map[string]bool // name -> user_id -> on list (web face)
|
|
// allowUUID mirrors the UUID-keyed server_allowlist table itself, the gate the
|
|
// internal/velocity wake uses (name -> mc_uuid -> on list). allowlist above is
|
|
// the account_links-bridged web view of the same data.
|
|
allowUUID map[string]map[string]bool
|
|
mine map[string][]MyServerView
|
|
claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
|
|
audits []AuditEntry
|
|
joins []string
|
|
// create-server seeding (spec §15)
|
|
seeded map[string]bool // name -> servers row exists
|
|
aliases map[string]string // subdomain -> bound server name
|
|
seedErr error
|
|
// account linking (spec §10)
|
|
linkCodes map[string]fakeLinkCode // code -> pending binding
|
|
links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid))
|
|
// linkAuthSource mirrors account_links.auth_source (mc_uuid -> mojang|thirdparty),
|
|
// the value copied from the consumed code at verify (migration 0005).
|
|
linkAuthSource map[string]string
|
|
// world backups (spec §7, §22). A nil slice lists empty.
|
|
backups []fakeBackup
|
|
// local-password auth (spec §B). staff is keyed by username (the login key);
|
|
// sessions by token_hash; settings by key. They mirror the PG contract so the
|
|
// hermetic tests exercise the same fail-closed semantics the integration impl
|
|
// honors.
|
|
staff map[string]*StaffUser // username -> staff login row
|
|
sessions map[string]*fakeSession // token_hash -> session
|
|
settings map[string][]byte // key -> jsonb value
|
|
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
|
// newest live (user, purpose) just as the PG query does.
|
|
otps map[string]*fakeEmailOTP
|
|
// username-collision reclaim (spec §B3). blacklist mirrors username_blacklist
|
|
// (mc_uuid -> barred), holds mirrors player_data_holds keyed by the held
|
|
// (squatter) mc_uuid — both keyed by UUID, matching the PG UNIQUE(mc_uuid)
|
|
// idempotency. They are written together by ReclaimUsername so the fake encodes
|
|
// the same all-or-nothing contract the PG transaction enforces.
|
|
blacklist map[string]bool
|
|
holds map[string]fakeDataHold
|
|
}
|
|
|
|
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
|
|
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
|
|
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
|
|
// fields — the inherit flow that would set them is CODE-ONLY (deferred).
|
|
type fakeDataHold struct {
|
|
id string
|
|
username string
|
|
dataRef string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the
|
|
// digits), attempts caps brute force, consumed marks single-use, and createdAt
|
|
// orders the newest-live lookup.
|
|
type fakeEmailOTP struct {
|
|
id string
|
|
userID string
|
|
email string
|
|
codeHash string
|
|
purpose string
|
|
attempts int
|
|
expiresAt time.Time
|
|
consumed bool
|
|
createdAt time.Time
|
|
}
|
|
|
|
// fakeSession mirrors a sessions row: its owner, its expiry, and whether it has
|
|
// been revoked.
|
|
type fakeSession struct {
|
|
userID string
|
|
expiresAt time.Time
|
|
revoked bool
|
|
}
|
|
|
|
// fakeBackup mirrors a world_backups row: the client-facing view plus the
|
|
// server-side backup_ref the list queries never expose.
|
|
type fakeBackup struct {
|
|
view BackupView
|
|
ref string
|
|
}
|
|
|
|
// fakeLinkCode mirrors an account_link_codes row.
|
|
type fakeLinkCode struct {
|
|
mcUUID string
|
|
authSource string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func newFakeRepo() *fakeRepo {
|
|
return &fakeRepo{
|
|
bySub: map[string]*ServerRecord{}, byName: map[string]*ServerRecord{},
|
|
linked: map[string]bool{}, quota: map[string]bool{},
|
|
allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
|
|
mine: map[string][]MyServerView{},
|
|
claimOK: map[string]bool{},
|
|
seeded: map[string]bool{}, aliases: map[string]string{},
|
|
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
|
|
linkAuthSource: map[string]string{},
|
|
staff: map[string]*StaffUser{},
|
|
sessions: map[string]*fakeSession{},
|
|
settings: map[string][]byte{},
|
|
otps: map[string]*fakeEmailOTP{},
|
|
blacklist: map[string]bool{},
|
|
holds: map[string]fakeDataHold{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeRepo) ServerBySubdomain(_ context.Context, s string) (*ServerRecord, error) {
|
|
if r, ok := f.bySub[s]; ok {
|
|
return r, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeRepo) ServerByName(_ context.Context, n string) (*ServerRecord, error) {
|
|
if r, ok := f.byName[n]; ok {
|
|
return r, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil }
|
|
func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil }
|
|
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
|
|
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
|
|
return nil
|
|
}
|
|
|
|
// VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests
|
|
// exercise the same contract the integration impl honors: strict expiry against
|
|
// the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the
|
|
// code, same (user, uuid) idempotent, the code's auth_source copied onto the link
|
|
// (and refreshed on re-verify), and the code consumed only on success.
|
|
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, string, error) {
|
|
rec, ok := f.linkCodes[code]
|
|
if !ok || !rec.expiresAt.After(now) {
|
|
return "", "", ErrLinkCodeInvalid
|
|
}
|
|
if existing, ok := f.links[rec.mcUUID]; ok && existing != userID {
|
|
return "", "", ErrConflict // do not consume another user's pending code
|
|
}
|
|
f.links[rec.mcUUID] = userID
|
|
f.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE
|
|
f.linked[userID] = true
|
|
delete(f.linkCodes, code)
|
|
return rec.mcUUID, rec.authSource, nil
|
|
}
|
|
|
|
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
|
|
// exercise the same semantics the integration impl honors: a fresh code supersedes
|
|
// the prior live one for (user, purpose), expiry and the attempt cap are checked
|
|
// before the hash compare, a wrong guess costs an attempt without consuming the
|
|
// code, and a match consumes it and flips the user row verified.
|
|
func (f *fakeRepo) CreateEmailOTP(_ context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error {
|
|
for k, o := range f.otps { // supersede any prior live code (DELETE ... consumed_at IS NULL)
|
|
if o.userID == userID && o.purpose == purpose && !o.consumed {
|
|
delete(f.otps, k)
|
|
}
|
|
}
|
|
f.otps[id] = &fakeEmailOTP{
|
|
id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose,
|
|
expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) (string, error) {
|
|
var live *fakeEmailOTP
|
|
for _, o := range f.otps { // newest live (user, purpose)
|
|
if o.userID != userID || o.purpose != purpose || o.consumed {
|
|
continue
|
|
}
|
|
if live == nil || o.createdAt.After(live.createdAt) {
|
|
live = o
|
|
}
|
|
}
|
|
if live == nil {
|
|
return "", ErrOTPInvalid
|
|
}
|
|
if !live.expiresAt.After(now) {
|
|
return "", ErrOTPInvalid
|
|
}
|
|
if live.attempts >= otpMaxAttempts {
|
|
return "", ErrOTPLocked
|
|
}
|
|
if live.codeHash != codeHash {
|
|
live.attempts++ // a typo costs an attempt but does not consume the code
|
|
return "", ErrOTPInvalid
|
|
}
|
|
live.consumed = true
|
|
for _, u := range f.staff { // flip the user row verified (UPDATE users ...)
|
|
if u.ID == userID {
|
|
u.Email = live.email
|
|
u.EmailVerified = true
|
|
}
|
|
}
|
|
return live.email, nil
|
|
}
|
|
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
|
|
return f.allowlist[n][u], nil
|
|
}
|
|
func (f *fakeRepo) UUIDInAllowlist(_ context.Context, n, uuid string) (bool, error) {
|
|
return f.allowUUID[n][uuid], nil
|
|
}
|
|
func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error) {
|
|
if u, ok := f.links[uuid]; ok {
|
|
return u, nil
|
|
}
|
|
return "", ErrNotFound
|
|
}
|
|
|
|
// ReclaimUsername mirrors PGRepo.ReclaimUsername: it bars the squatter UUID and
|
|
// stashes the data hold together (the all-or-nothing PG transaction), keyed by
|
|
// mc_uuid so a repeat reclaim of an already-barred UUID is an idempotent no-op
|
|
// (ON CONFLICT (mc_uuid) DO NOTHING on both tables) — the first reclaim wins and
|
|
// a duplicate neither errors nor overwrites the stored hold.
|
|
func (f *fakeRepo) ReclaimUsername(_ context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error) {
|
|
if h, ok := f.holds[squatterUUID]; ok { // already stashed — idempotent no-op; keep & report the first window
|
|
return h.expiresAt, nil
|
|
}
|
|
f.blacklist[squatterUUID] = true
|
|
f.holds[squatterUUID] = fakeDataHold{id: id, username: username, dataRef: dataRef, expiresAt: expiresAt}
|
|
return expiresAt, nil
|
|
}
|
|
func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool, error) {
|
|
return f.blacklist[mcUUID], nil
|
|
}
|
|
|
|
// IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked,
|
|
// auth_source 'thirdparty', and the linked user an admin — no password-hash test, so
|
|
// an SSO Operator (role='admin', empty PasswordHash) is protected like any other.
|
|
func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) {
|
|
userID, ok := f.links[mcUUID]
|
|
if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty {
|
|
return false, nil
|
|
}
|
|
for _, u := range f.staff {
|
|
if u.ID == userID && u.Role == "admin" {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) {
|
|
ok, present := f.claimOK[n]
|
|
if !present {
|
|
return false, ErrNotFound
|
|
}
|
|
return ok, nil
|
|
}
|
|
func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error {
|
|
if _, ok := f.byName[n]; !ok {
|
|
return ErrNotFound
|
|
}
|
|
f.joins = append(f.joins, n+":"+uuid)
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) {
|
|
return f.mine[u], nil
|
|
}
|
|
func (f *fakeRepo) SeedServer(_ context.Context, name, subdomain string) error {
|
|
if f.seedErr != nil {
|
|
return f.seedErr
|
|
}
|
|
if bound, ok := f.aliases[subdomain]; ok && bound != name {
|
|
return ErrConflict
|
|
}
|
|
f.seeded[name] = true
|
|
f.aliases[subdomain] = name
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) Audit(_ context.Context, e AuditEntry) error {
|
|
f.audits = append(f.audits, e)
|
|
return nil
|
|
}
|
|
|
|
// AllBackups / BackupsForUser / LatestBackup mirror the PG queries' contract so
|
|
// the hermetic tests can't pass against a too-lenient fake: only status='present'
|
|
// rows are visible, the user scope is the former_owner column, and LatestBackup
|
|
// is the newest present row for a server (or ErrNotFound).
|
|
func (f *fakeRepo) AllBackups(_ context.Context) ([]BackupView, error) {
|
|
var out []BackupView
|
|
for _, b := range f.backups {
|
|
if b.view.Status == "present" {
|
|
out = append(out, b.view)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeRepo) BackupsForUser(_ context.Context, userID string) ([]BackupView, error) {
|
|
var out []BackupView
|
|
for _, b := range f.backups {
|
|
if b.view.Status == "present" && b.view.FormerOwner == userID {
|
|
out = append(out, b.view)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRecord, error) {
|
|
var latest *fakeBackup
|
|
for i := range f.backups {
|
|
b := &f.backups[i]
|
|
if b.view.Status != "present" || b.view.ServerName != serverName {
|
|
continue
|
|
}
|
|
if latest == nil || b.view.CreatedAt.After(latest.view.CreatedAt) {
|
|
latest = b
|
|
}
|
|
}
|
|
if latest == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &BackupRecord{
|
|
ID: latest.view.ID, ServerName: latest.view.ServerName,
|
|
FormerOwner: latest.view.FormerOwner, BackupRef: latest.ref,
|
|
SizeBytes: latest.view.SizeBytes,
|
|
}, nil
|
|
}
|
|
|
|
// ---- local-password auth fakes (spec §B) ----
|
|
// Each method mirrors the PGRepo contract: a returned StaffUser is copied so a
|
|
// test cannot mutate the stored row by reference, SessionUser re-reads the
|
|
// CURRENT staff flags (so a password change clears must_change_password for live
|
|
// sessions just as the PG JOIN does), and the settings/sessions semantics match.
|
|
|
|
func (f *fakeRepo) UserByUsername(_ context.Context, username string) (*StaffUser, error) {
|
|
if u, ok := f.staff[username]; ok {
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeRepo) UserByID(_ context.Context, id string) (*StaffUser, error) {
|
|
for _, u := range f.staff {
|
|
if u.ID == id {
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
|
// Mirror PG ON CONFLICT (username): preserve the existing id so live sessions
|
|
// survive a password reset.
|
|
if existing, ok := f.staff[username]; ok {
|
|
id = existing.ID
|
|
}
|
|
f.staff[username] = &StaffUser{
|
|
ID: id, Username: username, Email: email, Role: "admin",
|
|
PasswordHash: passwordHash, MustChangePassword: mustChange,
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) SetPassword(_ context.Context, userID, passwordHash string) error {
|
|
for _, u := range f.staff {
|
|
if u.ID == userID {
|
|
u.PasswordHash = passwordHash
|
|
u.MustChangePassword = false
|
|
return nil
|
|
}
|
|
}
|
|
return ErrNotFound
|
|
}
|
|
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
|
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
|
|
s, ok := f.sessions[tokenHash]
|
|
if !ok || s.revoked || !s.expiresAt.After(now) {
|
|
return nil, ErrNotFound
|
|
}
|
|
for _, u := range f.staff {
|
|
if u.ID == s.userID {
|
|
return &SessionedUser{
|
|
ID: u.ID, Email: u.Email, Role: u.Role,
|
|
MustChangePassword: u.MustChangePassword,
|
|
}, nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
|
|
if s, ok := f.sessions[tokenHash]; ok {
|
|
s.revoked = true
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) RevokeUserSessionsExcept(_ context.Context, userID, keepTokenHash string) error {
|
|
for h, s := range f.sessions {
|
|
if s.userID == userID && h != keepTokenHash {
|
|
s.revoked = true
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
|
|
if v, ok := f.settings[key]; ok {
|
|
return v, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error {
|
|
f.settings[key] = value
|
|
return nil
|
|
}
|
|
|
|
// fakeRestorer records the restore it was asked to start and returns a canned
|
|
// error, mirroring the Restorer kick-off contract. The real restore Job is
|
|
// integration-only, so the handler is tested against this fake (spec §466).
|
|
type fakeRestorer struct {
|
|
err error
|
|
calls int
|
|
gotName string
|
|
gotRef string
|
|
}
|
|
|
|
func (f *fakeRestorer) Restore(_ context.Context, name, ref string) error {
|
|
f.calls++
|
|
f.gotName, f.gotRef = name, ref
|
|
return f.err
|
|
}
|
|
|
|
type fakeCluster struct {
|
|
byName map[string]*ServerInfo
|
|
bySub map[string]*ServerInfo
|
|
list []ServerInfo
|
|
desired map[string]v1alpha1.DesiredState
|
|
created map[string]CreateServerInput // name -> the validated input it was created from
|
|
patched map[string]ServerSpecPatch // name -> the validated spec patch it received
|
|
createErr error
|
|
}
|
|
|
|
func newFakeCluster() *fakeCluster {
|
|
return &fakeCluster{byName: map[string]*ServerInfo{}, bySub: map[string]*ServerInfo{},
|
|
desired: map[string]v1alpha1.DesiredState{}, created: map[string]CreateServerInput{},
|
|
patched: map[string]ServerSpecPatch{}}
|
|
}
|
|
func (c *fakeCluster) GetServer(_ context.Context, n string) (*ServerInfo, error) {
|
|
if s, ok := c.byName[n]; ok {
|
|
return s, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo, error) {
|
|
if v, ok := c.bySub[s]; ok {
|
|
return v, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil }
|
|
func (c *fakeCluster) SetDesiredState(_ context.Context, n string, s v1alpha1.DesiredState) error {
|
|
c.desired[n] = s
|
|
return nil
|
|
}
|
|
func (c *fakeCluster) CreateServer(_ context.Context, in CreateServerInput) error {
|
|
if c.createErr != nil {
|
|
return c.createErr
|
|
}
|
|
if _, ok := c.byName[in.Name]; ok {
|
|
return ErrConflict
|
|
}
|
|
c.created[in.Name] = in
|
|
info := &ServerInfo{Name: in.Name, Subdomain: in.Subdomain,
|
|
AutostartPolicy: string(in.AutostartPolicy),
|
|
DesiredState: string(v1alpha1.DesiredStopped), Phase: string(v1alpha1.PhaseStopped)}
|
|
c.byName[in.Name] = info
|
|
c.bySub[in.Subdomain] = info
|
|
return nil
|
|
}
|
|
func (c *fakeCluster) PatchServerSpec(_ context.Context, n string, p ServerSpecPatch) error {
|
|
info, ok := c.byName[n]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
c.patched[n] = p
|
|
// Apply only the fields the lifecycle view exposes, so a follow-up read sees
|
|
// the mutation (mirrors the real merge patch touching only non-nil fields).
|
|
if p.AutostartPolicy != nil {
|
|
info.AutostartPolicy = string(*p.AutostartPolicy)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fakeConsole records the command it was asked to run and returns a canned reply
|
|
// or error, mirroring the Console contract. The real K8sConsole's password
|
|
// resolution and RCON dial are integration-only, so the handler is tested
|
|
// against this fake (spec §8 写=RCON).
|
|
type fakeConsole struct {
|
|
reply string
|
|
err error
|
|
calls int
|
|
gotName string
|
|
gotCommand string
|
|
}
|
|
|
|
func (f *fakeConsole) RunCommand(_ context.Context, name, command string) (string, error) {
|
|
f.calls++
|
|
f.gotName, f.gotCommand = name, command
|
|
if f.err != nil {
|
|
return "", f.err
|
|
}
|
|
return f.reply, nil
|
|
}
|
|
|
|
// staticExternal injects a fixed principal so handler logic is tested without
|
|
// real JWT crypto (which is exercised separately in TestAccessVerifier).
|
|
type staticExternal struct {
|
|
p *Principal
|
|
err error
|
|
}
|
|
|
|
func (s staticExternal) Authenticate(*http.Request) (*Principal, error) { return s.p, s.err }
|
|
|
|
type okInternal struct{}
|
|
|
|
func (okInternal) Authenticate(*http.Request) error { return nil }
|
|
|
|
// ---- helpers ----
|
|
|
|
func newTestAPI(repo Repo, cl Cluster) *API {
|
|
return &API{Repo: repo, Cluster: cl, Internal: okInternal{}, RootDomain: testRoot,
|
|
Now: func() time.Time { return time.Unix(1_700_000_000, 0) }}
|
|
}
|
|
|
|
func do(h http.Handler, method, target, body string, headers map[string]string) *httptest.ResponseRecorder {
|
|
var r *http.Request
|
|
if body == "" {
|
|
r = httptest.NewRequest(method, target, nil)
|
|
} else {
|
|
r = httptest.NewRequest(method, target, strings.NewReader(body))
|
|
}
|
|
for k, v := range headers {
|
|
r.Header.Set(k, v)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func decodeErr(t *testing.T, w *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
var raw map[string]map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("error body not JSON: %v (%s)", err, w.Body.String())
|
|
}
|
|
return raw["error"]["code"]
|
|
}
|
|
|
|
// ---- dual-face separation ----
|
|
|
|
func TestInternalFaceRequiresServiceToken(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
|
h := api.InternalHandler()
|
|
|
|
// no token -> 401
|
|
if w := do(h, "GET", "/api/v1/servers", "", nil); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("no token: code = %d, want 401", w.Code)
|
|
}
|
|
// wrong token -> 401
|
|
if w := do(h, "GET", "/api/v1/servers", "", map[string]string{"Authorization": "Bearer nope"}); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("wrong token: code = %d, want 401", w.Code)
|
|
}
|
|
// right token -> 200
|
|
if w := do(h, "GET", "/api/v1/servers", "", map[string]string{"Authorization": "Bearer s3cr3t"}); w.Code != http.StatusOK {
|
|
t.Fatalf("right token: code = %d, want 200", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthzIsUnauthenticated(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
|
if w := do(api.InternalHandler(), "GET", "/healthz", "", nil); w.Code != http.StatusOK {
|
|
t.Fatalf("healthz code = %d, want 200", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestExternalFaceRequiresPrincipal(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.External = staticExternal{err: http.ErrNoCookie} // any auth error
|
|
if w := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", nil); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("code = %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestMeIdentity proves GET /api/v1/me reports the server-computed identity the
|
|
// panel uses to gate its Admin / SysAdmin navigation. The load-bearing assertion
|
|
// is the third subtest: is_admin tracks Principal.IsAdmin(), so the admin ROLE is
|
|
// not sufficient — the request must ALSO have arrived via the admin Access path
|
|
// (ViaAdminAccess). An admin who reached the panel through the ordinary app path
|
|
// therefore reads is_admin=false and the panel hides the admin surfaces (which the
|
|
// backend would 403 regardless). This keeps the client from re-deriving graded ZT.
|
|
func TestMeIdentity(t *testing.T) {
|
|
get := func(p *Principal) map[string]any {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.External = staticExternal{p: p}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/me", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (body %s)", w.Code, w.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
|
}
|
|
return got
|
|
}
|
|
|
|
t.Run("ordinary user: is_admin false", func(t *testing.T) {
|
|
got := get(&Principal{UserID: "u1", Email: "[email protected]", Role: "user"})
|
|
if got["user_id"] != "u1" || got["role"] != "user" || got["is_admin"] != false {
|
|
t.Fatalf("got %v, want user_id=u1 role=user is_admin=false", got)
|
|
}
|
|
})
|
|
|
|
t.Run("admin via admin Access path: is_admin true", func(t *testing.T) {
|
|
got := get(&Principal{UserID: "a1", Email: "[email protected]", Role: "admin", ViaAdminAccess: true})
|
|
if got["role"] != "admin" || got["is_admin"] != true {
|
|
t.Fatalf("got %v, want role=admin is_admin=true", got)
|
|
}
|
|
})
|
|
|
|
t.Run("admin via ordinary app path: is_admin false (graded ZT)", func(t *testing.T) {
|
|
got := get(&Principal{UserID: "a1", Email: "[email protected]", Role: "admin", ViaAdminAccess: false})
|
|
if got["role"] != "admin" {
|
|
t.Fatalf("role = %v, want admin", got["role"])
|
|
}
|
|
if got["is_admin"] != false {
|
|
t.Fatalf("is_admin = %v, want false — the admin role alone must not grant admin tier "+
|
|
"without the admin Access path", got["is_admin"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- by-host ----
|
|
|
|
func TestByHost(t *testing.T) {
|
|
cl := newFakeCluster()
|
|
cl.bySub["survival"] = &ServerInfo{Name: "survival", Subdomain: "survival", Phase: "Running", Ready: true}
|
|
api := newTestAPI(newFakeRepo(), cl)
|
|
h := api.InternalHandler()
|
|
tok := map[string]string{"Authorization": "Bearer "} // okInternal ignores it
|
|
|
|
t.Run("foreign domain rejected", func(t *testing.T) {
|
|
w := do(h, "GET", "/api/v1/servers/by-host/survival.evil.example.org", "", tok)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
})
|
|
t.Run("multi-label rejected", func(t *testing.T) {
|
|
w := do(h, "GET", "/api/v1/servers/by-host/a.b."+testRoot, "", tok)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
})
|
|
t.Run("unknown server 404", func(t *testing.T) {
|
|
w := do(h, "GET", "/api/v1/servers/by-host/creative."+testRoot, "", tok)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404", w.Code)
|
|
}
|
|
})
|
|
t.Run("found", func(t *testing.T) {
|
|
w := do(h, "GET", "/api/v1/servers/by-host/survival."+testRoot, "", tok)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
var info ServerInfo
|
|
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil || info.Name != "survival" {
|
|
t.Fatalf("unexpected body %s err %v", w.Body.String(), err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- fleet (SysAdmin cockpit read) ----
|
|
|
|
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
|
|
// fleet-wide. Two properties distinguish it from the app-tier /me/servers: a plain
|
|
// user is rejected by adminOnly before the handler runs, and an admin sees EVERY
|
|
// server the cluster reports (CRD truth via ListServers, §1) rather than a
|
|
// caller-scoped slice.
|
|
func TestFleetAdminRead(t *testing.T) {
|
|
cl := newFakeCluster()
|
|
cl.list = []ServerInfo{
|
|
{Name: "survival", Phase: "Running", Ready: true},
|
|
{Name: "creative", Phase: "Stopped"},
|
|
{Name: "skyblock", Phase: "Running", Ready: true},
|
|
}
|
|
|
|
t.Run("plain user forbidden", func(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), cl)
|
|
api.External = staticExternal{p: &Principal{UserID: "u", Role: "user"}}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (a plain user must not read the fleet)", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("admin via ordinary app path forbidden (graded ZT)", func(t *testing.T) {
|
|
// The admin ROLE alone is not enough: without the admin Access path adminOnly
|
|
// rejects, so the cockpit read cannot be reached by an admin who arrived via
|
|
// the ordinary app face — exactly as GET /api/v1/me reports is_admin=false there.
|
|
api := newTestAPI(newFakeRepo(), cl)
|
|
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
|
|
Role: "admin", ViaAdminAccess: false}}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (admin role without the admin Access path)", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("admin reads the whole fleet", func(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), cl)
|
|
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
|
|
Role: "admin", ViaAdminAccess: true}}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
var got map[string][]ServerInfo
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v", err)
|
|
}
|
|
// Fleet-wide: all three servers, not a caller-scoped subset.
|
|
if len(got["servers"]) != 3 {
|
|
t.Fatalf("servers = %d, want 3 (the fleet read must not be caller-scoped)", len(got["servers"]))
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- join-event ----
|
|
|
|
func TestJoinEvent(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival"}
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
h := api.InternalHandler()
|
|
|
|
t.Run("missing uuid 400", func(t *testing.T) {
|
|
w := do(h, "POST", "/api/v1/internal/servers/survival/join-event", `{}`, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
})
|
|
t.Run("records join", func(t *testing.T) {
|
|
w := do(h, "POST", "/api/v1/internal/servers/survival/join-event",
|
|
`{"mc_uuid":"11111111-1111-1111-1111-111111111111"}`, nil)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("code = %d, want 204 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if len(repo.joins) != 1 {
|
|
t.Fatalf("expected 1 recorded join, got %d", len(repo.joins))
|
|
}
|
|
})
|
|
t.Run("unknown server 404", func(t *testing.T) {
|
|
w := do(h, "POST", "/api/v1/internal/servers/missing/join-event",
|
|
`{"mc_uuid":"11111111-1111-1111-1111-111111111111"}`, nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- claim (§9.3) ----
|
|
|
|
func TestClaimStateMachine(t *testing.T) {
|
|
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user", ViaAdminAccess: false}
|
|
|
|
t.Run("not linked -> 412", func(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
api.External = staticExternal{p: user}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/claim", "", nil)
|
|
if w.Code != http.StatusPreconditionFailed || decodeErr(t, w) != "not_linked" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("over quota -> 403", func(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
repo.linked["u1"] = true
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
api.External = staticExternal{p: user}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/claim", "", nil)
|
|
if w.Code != http.StatusForbidden || decodeErr(t, w) != "quota_exceeded" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("already claimed -> 409", func(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
repo.linked["u1"] = true
|
|
repo.quota["u1"] = true
|
|
repo.claimOK["survival"] = false // row exists but owner already set
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
api.External = staticExternal{p: user}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/claim", "", nil)
|
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "already_claimed" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("success -> 200 + audit", func(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
repo.linked["u1"] = true
|
|
repo.quota["u1"] = true
|
|
repo.claimOK["survival"] = true
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
api.External = staticExternal{p: user}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/claim", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if len(repo.audits) != 1 || repo.audits[0].Action != "claim" || repo.audits[0].Actor != "[email protected]" {
|
|
t.Fatalf("audit not written as expected: %+v", repo.audits)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- wake (autostartPolicy gate + cooldown) ----
|
|
|
|
func TestWakeAutostartGate(t *testing.T) {
|
|
mk := func(policy string) (*API, *fakeCluster) {
|
|
repo := newFakeRepo()
|
|
cl := newFakeCluster()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", AutostartPolicy: policy}
|
|
api := newTestAPI(repo, cl)
|
|
return api, cl
|
|
}
|
|
stranger := &Principal{UserID: "stranger", Role: "user"}
|
|
|
|
t.Run("public: any user wakes", func(t *testing.T) {
|
|
api, cl := mk("public")
|
|
api.External = staticExternal{p: stranger}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil)
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
|
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
|
}
|
|
})
|
|
t.Run("ownerOnly: stranger forbidden", func(t *testing.T) {
|
|
api, cl := mk("ownerOnly")
|
|
api.External = staticExternal{p: stranger}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403", w.Code)
|
|
}
|
|
if _, set := cl.desired["survival"]; set {
|
|
t.Fatal("desiredState must not change on a forbidden wake")
|
|
}
|
|
})
|
|
t.Run("ownerOnly: owner wakes", func(t *testing.T) {
|
|
api, _ := mk("ownerOnly")
|
|
api.Repo.(*fakeRepo).byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
api.External = staticExternal{p: &Principal{UserID: "owner1", Role: "user"}}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil)
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("allowlist: only listed user wakes", func(t *testing.T) {
|
|
api, _ := mk("allowlist")
|
|
repo := api.Repo.(*fakeRepo)
|
|
repo.allowlist["survival"] = map[string]bool{"friend": true}
|
|
api.External = staticExternal{p: &Principal{UserID: "friend", Role: "user"}}
|
|
if w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusAccepted {
|
|
t.Fatalf("listed user: code = %d", w.Code)
|
|
}
|
|
api.External = staticExternal{p: stranger}
|
|
if w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusForbidden {
|
|
t.Fatalf("stranger: code = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWakeCooldown(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
cl := newFakeCluster()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", AutostartPolicy: "public"}
|
|
api := newTestAPI(repo, cl)
|
|
api.WakeCooldown = time.Minute
|
|
api.External = staticExternal{p: &Principal{UserID: "u", Role: "user"}}
|
|
h := api.ExternalHandler()
|
|
|
|
if w := do(h, "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusAccepted {
|
|
t.Fatalf("first wake code = %d", w.Code)
|
|
}
|
|
// clock is frozen, so the second wake is inside the cooldown window
|
|
if w := do(h, "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("second wake code = %d, want 429", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestWakeRunningCap exercises the §9.1 cluster-wide concurrency lever on the
|
|
// external wake path. The cap counts CRD truth via ListServers (never Postgres,
|
|
// per §1) and is a default-off lever: MaxRunningServers <= 0 disables it exactly
|
|
// as a zero WakeCooldown disables the per-server throttle. A full cluster answers
|
|
// 503 at_capacity — distinct from the cooldown's 429 — and an already-Running
|
|
// target re-wakes idempotently regardless of the cap.
|
|
func TestWakeRunningCap(t *testing.T) {
|
|
// capAPI builds an external-face API whose cluster already holds `running`
|
|
// servers desired-Running plus a stopped "survival" target, with the cap set.
|
|
capAPI := func(cap, running int) (*API, *fakeCluster) {
|
|
cl := newFakeCluster()
|
|
target := &ServerInfo{Name: "survival", AutostartPolicy: "public",
|
|
DesiredState: string(v1alpha1.DesiredStopped)}
|
|
cl.byName["survival"] = target
|
|
cl.list = []ServerInfo{*target}
|
|
for i := 0; i < running; i++ {
|
|
cl.list = append(cl.list, ServerInfo{Name: fmt.Sprintf("running-%d", i),
|
|
DesiredState: string(v1alpha1.DesiredRunning)})
|
|
}
|
|
api := newTestAPI(newFakeRepo(), cl)
|
|
api.MaxRunningServers = cap
|
|
api.External = staticExternal{p: &Principal{UserID: "u", Role: "user"}}
|
|
return api, cl
|
|
}
|
|
|
|
t.Run("at cap: wake rejected with 503 at_capacity", func(t *testing.T) {
|
|
api, cl := capAPI(2, 2)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503", w.Code)
|
|
}
|
|
if code := decodeErr(t, w); code != "at_capacity" {
|
|
t.Fatalf("error code = %q, want at_capacity", code)
|
|
}
|
|
if _, set := cl.desired["survival"]; set {
|
|
t.Fatal("desiredState must not change when the cluster is at capacity")
|
|
}
|
|
})
|
|
|
|
t.Run("below cap: wake accepted", func(t *testing.T) {
|
|
api, cl := capAPI(3, 2)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil)
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d, want 202", w.Code)
|
|
}
|
|
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
|
t.Fatalf("desired = %q, want Running", cl.desired["survival"])
|
|
}
|
|
})
|
|
|
|
t.Run("cap disabled (0): wake accepted even when many run", func(t *testing.T) {
|
|
api, _ := capAPI(0, 5)
|
|
if w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d, want 202", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("already-Running target re-wakes despite a full cap (idempotent)", func(t *testing.T) {
|
|
api, cl := capAPI(2, 2)
|
|
cl.byName["survival"].DesiredState = string(v1alpha1.DesiredRunning)
|
|
if w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d, want 202 (idempotent re-wake)", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestWakeCooldownNotBurnedAtCapacity is the §9.1 regression guard for the
|
|
// cooldown/cap ordering: a wake the running-cap refuses with 503 must NOT start
|
|
// the per-server cooldown. Otherwise a player held because the cluster was
|
|
// momentarily full would, once a slot frees, still be made to wait out a 30s
|
|
// cooldown their refused wake never earned. With the clock frozen, a 503 followed
|
|
// by the same server waking the instant capacity frees must return 202, not 429.
|
|
func TestWakeCooldownNotBurnedAtCapacity(t *testing.T) {
|
|
cl := newFakeCluster()
|
|
target := &ServerInfo{Name: "survival", AutostartPolicy: "public",
|
|
DesiredState: string(v1alpha1.DesiredStopped)}
|
|
cl.byName["survival"] = target
|
|
cl.list = []ServerInfo{*target, {Name: "other", DesiredState: string(v1alpha1.DesiredRunning)}}
|
|
api := newTestAPI(newFakeRepo(), cl)
|
|
api.WakeCooldown = time.Minute
|
|
api.MaxRunningServers = 1
|
|
api.External = staticExternal{p: &Principal{UserID: "u", Role: "user"}}
|
|
h := api.ExternalHandler()
|
|
|
|
// The cluster is full (1 running == cap): the wake is refused with 503 and must
|
|
// leave the cooldown unstarted.
|
|
if w := do(h, "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("at-capacity wake code = %d, want 503", w.Code)
|
|
}
|
|
if _, set := cl.desired["survival"]; set {
|
|
t.Fatal("desiredState must not change when refused at capacity")
|
|
}
|
|
|
|
// A slot frees (the other server is gone). The same server, same frozen clock,
|
|
// must now wake — a 429 here would prove the 503 had burned the cooldown.
|
|
cl.list = []ServerInfo{*target}
|
|
if w := do(h, "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != http.StatusAccepted {
|
|
t.Fatalf("post-capacity wake code = %d, want 202 (the 503 must not burn the cooldown)", w.Code)
|
|
}
|
|
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
|
t.Fatalf("desired = %q, want Running", cl.desired["survival"])
|
|
}
|
|
}
|
|
|
|
// ---- Zero-Trust admin boundary (§14) ----
|
|
|
|
func TestAdminBoundary(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
|
|
t.Run("user role rejected before handler", func(t *testing.T) {
|
|
api.External = staticExternal{p: &Principal{UserID: "u", Role: "user", ViaAdminAccess: false}}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers", `{}`, nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
t.Run("admin role without admin access rejected", func(t *testing.T) {
|
|
api.External = staticExternal{p: &Principal{UserID: "a", Role: "admin", ViaAdminAccess: false}}
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers", `{}`, nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("role=admin but panel path: code = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
t.Run("admin via admin-access reaches handler", func(t *testing.T) {
|
|
api.External = staticExternal{p: &Principal{UserID: "a", Role: "admin", ViaAdminAccess: true}}
|
|
// The body is empty so the real handler rejects it (validation 400 with no
|
|
// Builder → 503), but the point is that the admin Zero-Trust path is NOT
|
|
// stopped at the 403 boundary — it reaches the handler.
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/servers", `{}`, nil)
|
|
if w.Code == http.StatusForbidden {
|
|
t.Fatalf("admin via admin-access must reach the handler, got 403")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- error envelope ----
|
|
|
|
func TestErrorEnvelopeHasRequestID(t *testing.T) {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.External = staticExternal{p: &Principal{UserID: "u", Role: "user"}}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/status", "", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404", w.Code)
|
|
}
|
|
if w.Header().Get("X-Request-Id") == "" {
|
|
t.Fatal("missing X-Request-Id response header")
|
|
}
|
|
var raw map[string]map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("body not JSON: %v", err)
|
|
}
|
|
if raw["error"]["request_id"] == "" {
|
|
t.Fatal("error envelope missing request_id")
|
|
}
|
|
}
|
|
|
|
// ---- real AccessVerifier (JWT aud) ----
|
|
|
|
func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
|
repo.staff["owner"] = &StaffUser{ID: "u1", Email: "[email protected]", Role: "admin"}
|
|
token := "session-token"
|
|
repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: time.Now().Add(time.Hour)}
|
|
auth := SessionAuth{Repo: repo, RootDomain: "old.example.net", AdminHostname: "op.console.mc.example.net"}
|
|
|
|
r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil)
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
|
p, err := auth.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
if !p.ViaAdminAccess {
|
|
t.Fatalf("configured admin hostname should grant admin-path access, got %+v", p)
|
|
}
|
|
|
|
r = httptest.NewRequest("GET", "https://op.console.old.example.net/api/v1/me", nil)
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
|
p, err = auth.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate fallback host: %v", err)
|
|
}
|
|
if p.ViaAdminAccess {
|
|
t.Fatalf("root-domain fallback host must not grant admin-path access when admin_hostname is configured")
|
|
}
|
|
|
|
r = httptest.NewRequest("GET", "https://10.211.55.4:30443/api/v1/me", nil)
|
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
|
p, err = auth.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate private IP host: %v", err)
|
|
}
|
|
if !p.ViaAdminAccess {
|
|
t.Fatalf("private IP local panel should grant admin-path access, got %+v", p)
|
|
}
|
|
}
|
|
func TestAccessVerifier(t *testing.T) {
|
|
key := []byte("test-signing-key")
|
|
keyfunc := func(*jwt.Token) (any, error) { return key, nil }
|
|
v := AccessVerifier{Audience: "felis-app", AdminAudience: "felis-admin", Keyfunc: keyfunc}
|
|
|
|
sign := func(claims accessClaims) string {
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
s, err := tok.SignedString(key)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
exp := jwt.NewNumericDate(time.Now().Add(time.Hour))
|
|
|
|
t.Run("valid app token", func(t *testing.T) {
|
|
s := sign(accessClaims{Email: "[email protected]", RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}, ExpiresAt: exp}})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+s)
|
|
p, err := v.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("authenticate: %v", err)
|
|
}
|
|
if p.UserID != "u1" || p.Email != "[email protected]" || p.Role != "user" || p.ViaAdminAccess {
|
|
t.Fatalf("unexpected principal %+v", p)
|
|
}
|
|
})
|
|
t.Run("admin audience sets ViaAdminAccess", func(t *testing.T) {
|
|
s := sign(accessClaims{Role: "admin", RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "a1", Audience: jwt.ClaimStrings{"felis-app", "felis-admin"}, ExpiresAt: exp}})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Cf-Access-Jwt-Assertion", s)
|
|
p, err := v.Authenticate(r)
|
|
if err != nil {
|
|
t.Fatalf("authenticate: %v", err)
|
|
}
|
|
if !p.IsAdmin() {
|
|
t.Fatalf("expected admin principal, got %+v", p)
|
|
}
|
|
})
|
|
t.Run("wrong audience rejected", func(t *testing.T) {
|
|
s := sign(accessClaims{RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "u1", Audience: jwt.ClaimStrings{"someone-else"}, ExpiresAt: exp}})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+s)
|
|
if _, err := v.Authenticate(r); err == nil {
|
|
t.Fatal("expected audience rejection")
|
|
}
|
|
})
|
|
t.Run("wrong signing key rejected", func(t *testing.T) {
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims{RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}, ExpiresAt: exp}})
|
|
s, _ := tok.SignedString([]byte("attacker-key"))
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+s)
|
|
if _, err := v.Authenticate(r); err == nil {
|
|
t.Fatal("expected signature rejection")
|
|
}
|
|
})
|
|
t.Run("missing expiry rejected", func(t *testing.T) {
|
|
s := sign(accessClaims{RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}}})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+s)
|
|
if _, err := v.Authenticate(r); err == nil {
|
|
t.Fatal("expected missing-expiry rejection")
|
|
}
|
|
})
|
|
}
|