338 lines
12 KiB
Go
338 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
)
|
|
|
|
// The internal-face claim + menu pair (spec §9.3, §12) is what velocity drives for
|
|
// the felis-paper lobby, which holds no token of its own. The claim mirrors the
|
|
// external Principal-gated claim but keys identity off the verified online-mode
|
|
// UUID; the menu adds the ownership-derived `claimable` flag the §11 lifecycle
|
|
// views never carry. These assert the exact wire keys, not just status, because a
|
|
// key velocity's parser reads but the handler never emits fails silently — every
|
|
// tile would read claimable=false forever and a status-only test would still pass.
|
|
|
|
const menuUUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
|
|
func internalClaim(api *API, body string) *httptest.ResponseRecorder {
|
|
return do(api.InternalHandler(), "POST", "/api/v1/internal/servers/survival/claim", body, nil)
|
|
}
|
|
|
|
func internalMenu(api *API) *httptest.ResponseRecorder {
|
|
return do(api.InternalHandler(), "GET", "/api/v1/internal/servers/survival/menu", "", nil)
|
|
}
|
|
|
|
func TestInternalClaim(t *testing.T) {
|
|
body := `{"mc_uuid":"` + menuUUID + `"}`
|
|
|
|
// linkAndQuota wires an API whose menuUUID is linked to userID and (optionally)
|
|
// has quota headroom, with the survival server present and claimable.
|
|
setup := func() (*API, *fakeRepo) {
|
|
repo := newFakeRepo()
|
|
cl := newFakeCluster()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped"}
|
|
return newTestAPI(repo, cl), repo
|
|
}
|
|
|
|
t.Run("happy path: linked + quota + ownerless → 200 claimed", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.links[menuUUID] = "user1" // account_links: UUID → user1 (implies linked)
|
|
repo.quota["user1"] = true
|
|
repo.claimOK["survival"] = true // UPDATE ... WHERE owner_id IS NULL hits 1 row
|
|
|
|
w := internalClaim(api, body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d 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())
|
|
}
|
|
// Exact wire contract velocity's ClaimResponse parser reads.
|
|
if got["name"] != "survival" {
|
|
t.Fatalf("name = %v, want survival", got["name"])
|
|
}
|
|
if got["claimed"] != true {
|
|
t.Fatalf("claimed = %v, want true", got["claimed"])
|
|
}
|
|
// The claim is by the resolved user, and it is audited as a velocity action.
|
|
repo.assertClaimAudit(t, "survival")
|
|
})
|
|
|
|
t.Run("unlinked UUID → 412 not_linked, no claim", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.claimOK["survival"] = true // would succeed if we got that far
|
|
// menuUUID is absent from repo.links → UserByMCUUID returns ErrNotFound.
|
|
|
|
w := internalClaim(api, body)
|
|
if w.Code != http.StatusPreconditionFailed {
|
|
t.Fatalf("code = %d, want 412 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "not_linked" {
|
|
t.Fatalf("error code = %q, want not_linked", code)
|
|
}
|
|
if len(repo.audits) != 0 {
|
|
t.Fatal("an unlinked claim must not be audited as a claim")
|
|
}
|
|
})
|
|
|
|
t.Run("over quota → 403 quota_exceeded", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.links[menuUUID] = "user1"
|
|
repo.quota["user1"] = false // at the ceiling
|
|
repo.claimOK["survival"] = true
|
|
|
|
w := internalClaim(api, body)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "quota_exceeded" {
|
|
t.Fatalf("error code = %q, want quota_exceeded", code)
|
|
}
|
|
})
|
|
|
|
t.Run("already claimed (lost race) → 409", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.links[menuUUID] = "user1"
|
|
repo.quota["user1"] = true
|
|
repo.claimOK["survival"] = false // present but UPDATE hits 0 rows
|
|
|
|
w := internalClaim(api, body)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "already_claimed" {
|
|
t.Fatalf("error code = %q, want already_claimed", code)
|
|
}
|
|
})
|
|
|
|
t.Run("missing server → 404, distinct from the unlinked 412", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.links[menuUUID] = "user1"
|
|
repo.quota["user1"] = true
|
|
// survival absent from repo.claimOK → ClaimServer returns ErrNotFound.
|
|
|
|
w := internalClaim(api, body)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("missing mc_uuid is rejected", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.claimOK["survival"] = true
|
|
w := internalClaim(api, `{}`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInternalMenuStatus(t *testing.T) {
|
|
setup := func() (*API, *fakeRepo, *fakeCluster) {
|
|
repo := newFakeRepo()
|
|
cl := newFakeCluster()
|
|
return newTestAPI(repo, cl), repo, cl
|
|
}
|
|
|
|
t.Run("ownerless server → claimable, with exact keys", func(t *testing.T) {
|
|
api, _, cl := setup()
|
|
cl.byName["survival"] = &ServerInfo{
|
|
Name: "survival", Phase: "Running", Ready: true,
|
|
PlayersOnline: 3, PlayersMax: 20,
|
|
}
|
|
// no servers-row at all → treated as ownerless → claimable.
|
|
|
|
w := internalMenu(api)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
got := decodeMenu(t, w)
|
|
// Assert every key the lobby's MenuStatus parser reads, by exact name —
|
|
// the §11-trap guard: a renamed/absent key would read as a zero value.
|
|
assertEq(t, "name", got["name"], "survival")
|
|
assertEq(t, "phase", got["phase"], "Running")
|
|
assertEq(t, "ready", got["ready"], true)
|
|
assertEq(t, "playersOnline", got["playersOnline"], float64(3))
|
|
assertEq(t, "playersMax", got["playersMax"], float64(20))
|
|
assertEq(t, "claimable", got["claimable"], true)
|
|
})
|
|
|
|
t.Run("owned server → not claimable", func(t *testing.T) {
|
|
api, repo, cl := setup()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped"}
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
|
|
got := decodeMenu(t, internalMenu(api))
|
|
assertEq(t, "claimable", got["claimable"], false)
|
|
})
|
|
|
|
t.Run("seeded but ownerless (empty owner_id) → claimable", func(t *testing.T) {
|
|
api, repo, cl := setup()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped"}
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: ""}
|
|
|
|
got := decodeMenu(t, internalMenu(api))
|
|
assertEq(t, "claimable", got["claimable"], true)
|
|
})
|
|
|
|
t.Run("unknown server → 404", func(t *testing.T) {
|
|
api, _, _ := setup()
|
|
if w := internalMenu(api); w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- local helpers ----
|
|
|
|
func decodeMenu(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d 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
|
|
}
|
|
|
|
func assertEq(t *testing.T, key string, got, want any) {
|
|
t.Helper()
|
|
if got != want {
|
|
t.Fatalf("%s = %v (%T), want %v (%T)", key, got, got, want, want)
|
|
}
|
|
}
|
|
|
|
func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
|
|
t.Helper()
|
|
for _, e := range f.audits {
|
|
if e.Action == "claim" && e.ServerName == name && e.Actor == "velocity" && e.Source == "internal:velocity" {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("no velocity/internal claim audit for %q in %+v", name, f.audits)
|
|
}
|
|
|
|
func internalMenuAccess(api *API, uuid string) *httptest.ResponseRecorder {
|
|
return do(api.InternalHandler(), "GET", "/api/v1/internal/player/menu-access/"+uuid, "", nil)
|
|
}
|
|
|
|
// The menu-access verdicts tell the lobby, per tile, whether this player may start
|
|
// the server and why not, with the wake's own gates behind each one.
|
|
func TestInternalMenuAccess(t *testing.T) {
|
|
gone := &RetireState{RequestedAt: time.Now()}
|
|
setup := func() (*API, *fakeRepo) {
|
|
repo := newFakeRepo()
|
|
cl := newFakeCluster()
|
|
running := string(v1alpha1.DesiredRunning)
|
|
cl.list = []ServerInfo{
|
|
{Name: "pub", AutostartPolicy: "public"},
|
|
{Name: "mine", AutostartPolicy: "ownerOnly"},
|
|
{Name: "theirs", AutostartPolicy: "ownerOnly"},
|
|
{Name: "unset"},
|
|
{Name: "listed", AutostartPolicy: "allowlist"},
|
|
{Name: "unlisted", AutostartPolicy: "allowlist"},
|
|
{Name: "ownerless", AutostartPolicy: "ownerOnly"},
|
|
{Name: "gone", AutostartPolicy: "public"},
|
|
{Name: "mine-gone", AutostartPolicy: "ownerOnly"},
|
|
{Name: "broken", AutostartPolicy: "public", StartGaveUp: true, DesiredState: running},
|
|
{Name: "mine-broken", AutostartPolicy: "ownerOnly", StartGaveUp: true, DesiredState: running},
|
|
{Name: "broken-stopped", AutostartPolicy: "public", StartGaveUp: true},
|
|
{Name: "lobby", AutostartPolicy: "public"},
|
|
}
|
|
repo.owners = map[string]ServerOwnership{
|
|
"pub": {OwnerID: "u2"},
|
|
"mine": {OwnerID: "user1"},
|
|
"theirs": {OwnerID: "u2"},
|
|
"unset": {OwnerID: "u2"},
|
|
"listed": {OwnerID: "u2"},
|
|
"unlisted": {OwnerID: "u2"},
|
|
"ownerless": {},
|
|
"gone": {OwnerID: "u2", Retire: gone},
|
|
"mine-gone": {OwnerID: "user1", Retire: gone},
|
|
"broken": {OwnerID: "u2"},
|
|
"mine-broken": {OwnerID: "user1"},
|
|
"broken-stopped": {OwnerID: "u2"},
|
|
}
|
|
repo.allowUUID["listed"] = map[string]bool{menuUUID: true}
|
|
return newTestAPI(repo, cl), repo
|
|
}
|
|
verdicts := func(t *testing.T, api *API) map[string]any {
|
|
t.Helper()
|
|
got := decodeMenu(t, internalMenuAccess(api, menuUUID))
|
|
servers, ok := got["servers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("servers = %v, want an object", got["servers"])
|
|
}
|
|
return servers
|
|
}
|
|
|
|
t.Run("a linked player", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.links[menuUUID] = "user1"
|
|
got := verdicts(t, api)
|
|
for name, want := range map[string]string{
|
|
"pub": "wake",
|
|
"mine": "owner",
|
|
"theirs": "owner_only",
|
|
"unset": "owner_only",
|
|
"listed": "wake",
|
|
"unlisted": "allowlist",
|
|
"ownerless": "owner_only",
|
|
"gone": "retiring",
|
|
"mine-gone": "retiring",
|
|
"broken": "start_failed",
|
|
"mine-broken": "start_failed",
|
|
"broken-stopped": "wake",
|
|
} {
|
|
assertEq(t, name, got[name], want)
|
|
}
|
|
if _, listed := got["lobby"]; listed {
|
|
t.Fatal("the lobby is not a menu tile, yet it has a verdict")
|
|
}
|
|
assertEq(t, "verdict count", len(got), 12)
|
|
})
|
|
|
|
t.Run("staff start any server that is not retiring or failed", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.links[menuUUID] = "a1"
|
|
repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Role: "admin"}
|
|
got := verdicts(t, api)
|
|
assertEq(t, "theirs", got["theirs"], "wake")
|
|
assertEq(t, "unlisted", got["unlisted"], "wake")
|
|
assertEq(t, "ownerless", got["ownerless"], "wake")
|
|
assertEq(t, "gone", got["gone"], "retiring")
|
|
})
|
|
|
|
t.Run("an unlinked UUID owns nothing, not even an ownerless server", func(t *testing.T) {
|
|
api, _ := setup()
|
|
got := verdicts(t, api)
|
|
assertEq(t, "mine", got["mine"], "owner_only")
|
|
assertEq(t, "ownerless", got["ownerless"], "owner_only")
|
|
assertEq(t, "listed", got["listed"], "wake")
|
|
assertEq(t, "pub", got["pub"], "wake")
|
|
})
|
|
|
|
t.Run("a failed read fails the call", func(t *testing.T) {
|
|
api, repo := setup()
|
|
repo.ownersErr = errors.New("db down")
|
|
if w := internalMenuAccess(api, menuUUID); w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("owners unreadable: code = %d, want 500 (%s)", w.Code, w.Body.String())
|
|
}
|
|
api, _ = setup()
|
|
api.Cluster.(*fakeCluster).listErr = errors.New("apiserver down")
|
|
if w := internalMenuAccess(api, menuUUID); w.Code < 500 {
|
|
t.Fatalf("cluster unreadable: code = %d, want 5xx (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|