Loading .github/workflows/ci.yml +29 −0 Changes for .github/workflows/ci.yml: 29 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -165,9 +165,38 @@ jobs: - run: npm test working-directory: panel # `tsc -b`: tsconfig.json is a solution file (files: [] plus references), so a plain # `tsc --noEmit` checked nothing and passed with type errors in the tree. - run: npm run typecheck working-directory: panel # Rules of hooks and effect dependency lists, with --max-warnings 0. - run: npm run lint working-directory: panel # openapi.gen.ts is generated from docs/openapi.yaml and checked in, so the # compile-time parity in src/lib/types.parity.ts needs no generator in the build; # a schema edit that was not regenerated fails here. - name: openapi.gen.ts matches docs/openapi.yaml working-directory: panel run: | npm run gen:api git diff --exit-code -- src/lib/openapi.gen.ts # Browser smoke over the mock-mode dev server, in the runner's installed Chrome # (playwright.config.ts sets channel: chrome, so nothing is downloaded). - run: npm run test:e2e working-directory: panel - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: name: panel-playwright-report path: | panel/playwright-report panel/test-results retention-days: 7 plugins: runs-on: ubuntu-latest steps: Loading docs/openapi.yaml +52 −26 Changes for docs/openapi.yaml: 52 added lines, 26 removed lines. Original line number Diff line number Diff line Loading @@ -303,7 +303,7 @@ components: ServerInfo: type: object description: Status projection of one server (internal/api/cluster.go ServerInfo). required: [name, subdomain, phase, ready, playersOnline, playersMax] required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds] properties: name: { type: string } subdomain: { type: string } Loading @@ -311,6 +311,7 @@ components: ready: { type: boolean } autostartPolicy: type: string enum: [ownerOnly, public, allowlist] description: Present only when set; who may wake the server via domain-autostart. desiredState: type: string Loading @@ -333,6 +334,26 @@ components: type: boolean description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. FleetServer: description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). allOf: - $ref: '#/components/schemas/ServerInfo' - type: object properties: owner: type: string description: >- The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. system: type: boolean description: >- True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. MyServerView: type: object description: One row of the caller's server list (internal/api/repo.go MyServerView). Loading @@ -350,6 +371,20 @@ components: format: int32 description: Best-effort from live CRD status; 0 when the cluster is unreachable. playersMax: { type: integer, format: int32 } displayName: type: string description: From live CRD status; omitted when unset or the cluster is unreachable. desiredState: type: string enum: [Running, Stopped] description: Owned rows only, from live CRD status. autostartPolicy: type: string enum: [ownerOnly, public, allowlist] description: Owned rows only, from live CRD status. playerCountUnknown: type: boolean description: Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players. BackupView: type: object Loading Loading @@ -391,6 +426,9 @@ components: enum: [pending, building, succeeded, failed, cancelled] dockerfile: { type: string } context_ref: { type: string } context_digest: type: string description: Lowercase hex sha256 of the context tarball the build was pinned to (the audit record). Omitted when the request named none. base_image: { type: string } requested_by: { type: string } job_name: { type: string } Loading @@ -398,9 +436,9 @@ components: error: { type: string } created_at: { type: string, format: date-time } finished_at: type: [string, 'null'] type: string format: date-time description: Null until the build reaches a terminal status. description: Omitted until the build reaches a terminal status. Image: type: object Loading Loading @@ -430,6 +468,9 @@ components: context_ref: type: string description: Platform-derived pinned build context; not user-supplied. context_sha256: type: string description: Lowercase hex sha256 of the uploaded context tarball; omitted until one is uploaded. Approval must name it. status: type: string enum: [pending_review, approved, rejected] Loading @@ -456,9 +497,9 @@ components: reject_reason: { type: string } created_at: { type: string, format: date-time } reviewed_at: type: [string, 'null'] type: string format: date-time description: Null until an admin approves or rejects. description: Omitted until an admin approves or rejects. UserView: type: object Loading @@ -478,7 +519,7 @@ components: UserDetail: type: object description: Full admin view of one user (internal/api/repo.go UserDetail). required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts] required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at] properties: id: { type: string } username: { type: string } Loading @@ -490,11 +531,12 @@ components: created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: type: [string, 'null'] type: string format: date-time description: Present only when soft-deleted. linked_accounts: type: array description: Omitted when the user has no linked Minecraft account. items: type: object required: [mc_uuid, auth_source, verified_at] Loading Loading @@ -523,8 +565,9 @@ components: created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } revoked_at: type: [string, 'null'] type: string format: date-time description: Present only once the session is revoked. paths: # ----------------------------------------------------------------- health --- Loading Loading @@ -2922,24 +2965,7 @@ paths: properties: servers: type: array items: allOf: - $ref: '#/components/schemas/ServerInfo' - type: object properties: owner: type: string description: >- The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. system: type: boolean description: >- True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. items: { $ref: '#/components/schemas/FleetServer' } '401': $ref: '#/components/responses/Unauthorized' '403': Loading internal/api/api_test.go +75 −2 Changes for internal/api/api_test.go: 75 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -746,7 +746,9 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error { return nil } func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) { return f.mine[u], nil // Fresh rows per call, as PGRepo scans them: the handler joins live state // into the slice it gets. return append([]MyServerView(nil), f.mine[u]...), nil } func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) { if f.ownersErr != nil { Loading Loading @@ -1535,6 +1537,7 @@ type fakeCluster struct { byName map[string]*ServerInfo bySub map[string]*ServerInfo list []ServerInfo listErr error 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 Loading Loading @@ -1567,7 +1570,12 @@ func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo, } return nil, ErrNotFound } func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil } func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { if c.listErr != nil { return nil, c.listErr } return c.list, nil } func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr } // WorldVolumeExists models the world PVC: present unless the test named the Loading Loading @@ -1860,6 +1868,71 @@ func TestMeIdentity(t *testing.T) { }) } // TestMyServersJoinsLiveState proves /me/servers carries the live CRD fields the // panel renders: every row gets the display name, live phase and counts, and // only the caller's own rows get owner detail (desired state, autostart policy, // an unreadable player count), as the status route withholds them from others. // A cluster read failure keeps the Postgres rows with the cached phase. func TestMyServersJoinsLiveState(t *testing.T) { repo := newFakeRepo() repo.mine = map[string][]MyServerView{"u1": { {Name: "mine", Subdomain: "mine", Owned: true, Phase: "Stopped"}, {Name: "open", Subdomain: "open", Claimable: true, Phase: "Stopped"}, }} cl := newFakeCluster() cl.list = []ServerInfo{ {Name: "mine", DisplayName: "My World", Phase: "Running", DesiredState: "Running", AutostartPolicy: "ownerOnly", PlayersOnline: 2, PlayersMax: 20, PlayerCountUnknown: true}, {Name: "open", DisplayName: "Open World", Phase: "Running", DesiredState: "Running", AutostartPolicy: "public", PlayersMax: 10, PlayerCountUnknown: true}, } api := newTestAPI(repo, cl) api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}} read := func() map[string]map[string]any { t.Helper() w := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) } var body struct { Servers []map[string]any `json:"servers"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } out := map[string]map[string]any{} for _, s := range body.Servers { out[s["name"].(string)] = s } return out } got := read() mine, open := got["mine"], got["open"] for k, want := range map[string]any{"displayName": "My World", "phase": "Running", "desiredState": "Running", "autostartPolicy": "ownerOnly", "playerCountUnknown": true, "playersOnline": float64(2), "playersMax": float64(20)} { if mine[k] != want { t.Errorf("own row %s = %v, want %v", k, mine[k], want) } } if open["displayName"] != "Open World" || open["phase"] != "Running" || open["playersMax"] != float64(10) { t.Errorf("claimable row public fields = %v", open) } for _, k := range []string{"desiredState", "autostartPolicy", "playerCountUnknown"} { if _, ok := open[k]; ok { t.Errorf("claimable row carries owner detail %s = %v", k, open[k]) } } cl.listErr = errors.New("apiserver down") got = read() if got["mine"]["phase"] != "Stopped" || got["mine"]["playersOnline"] != float64(0) { t.Errorf("cluster down: own row = %v, want cached phase and 0 players", got["mine"]) } } // ---- fleet (SysAdmin cockpit read) ---- // TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND Loading internal/api/handlers_user.go +21 −6 Changes for internal/api/handlers_user.go: 21 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -239,19 +239,34 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } // Player counts are presentational and best-effort, mirroring handleFleet's // The live fields are presentational and best-effort, mirroring handleFleet's // owner join: the list exists for ownership/claim state, so a cluster hiccup // must degrade to 0/0 counts, never 500 the whole list. The CRD status is the // only source of live counts (spec §1) — Postgres never stores them. // must degrade to 0/0 counts and the cached phase, never 500 the whole list. // The CRD status is the only source of live state (spec §1) — Postgres never // stores counts. Owner detail (desired state, autostart policy, whether the // count is readable) joins only onto rows the caller owns; the panel needs // playerCountUnknown there to ask before a stop that may drop players. if infos, err := a.Cluster.ListServers(r.Context()); err == nil { byName := make(map[string]ServerInfo, len(infos)) for _, s := range infos { byName[s.Name] = s } for i := range servers { if info, ok := byName[servers[i].Name]; ok { servers[i].PlayersOnline = info.PlayersOnline servers[i].PlayersMax = info.PlayersMax info, ok := byName[servers[i].Name] if !ok { continue } v := &servers[i] v.PlayersOnline = info.PlayersOnline v.PlayersMax = info.PlayersMax v.DisplayName = info.DisplayName if info.Phase != "" { v.Phase = info.Phase } if v.Owned { v.DesiredState = info.DesiredState v.AutostartPolicy = info.AutostartPolicy v.PlayerCountUnknown = info.PlayerCountUnknown } } } Loading internal/api/openapi_parity_test.go 0 → 100644 +190 −0 Changes for internal/api/openapi_parity_test.go: 190 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "os" "reflect" "sort" "strings" "testing" "felis.lolicon.best/internal/build" "sigs.k8s.io/yaml" ) // TestOpenAPISchemasMatchWireStructs keeps docs/openapi.yaml honest about the // response bodies. Each named schema is compared with the Go struct the handler // actually encodes: the property set must equal the struct's JSON field set, and // `required` must list exactly the fields that are always on the wire (no // omitempty). The panel's types are checked against the same schemas at compile // time (panel/src/lib/types.parity.ts), so a field added here without the docs // fails in Go, and one added to the docs without the panel fails in tsc. func TestOpenAPISchemasMatchWireStructs(t *testing.T) { raw, err := os.ReadFile("../../docs/openapi.yaml") if err != nil { t.Fatal(err) } var doc struct { Components struct { Schemas map[string]schemaDoc `json:"schemas"` } `json:"components"` } if err := yaml.Unmarshal(raw, &doc); err != nil { t.Fatalf("parse openapi.yaml: %v", err) } pairs := map[string]any{ "ServerInfo": ServerInfo{}, "FleetServer": fleetServerView{}, "MyServerView": MyServerView{}, "BackupView": BackupView{}, "Build": build.Build{}, "Image": build.Image{}, "Submission": submissionView{}, "UserView": UserView{}, "UserDetail": UserDetail{}, "QuotaView": QuotaView{}, "SessionView": SessionView{}, "PasskeyCredential": passkeyCredentialView{}, "UpdateWindow": updateWindow{}, "DBBackupStatus": dbBackupView{}, } for name, v := range pairs { s, ok := doc.Components.Schemas[name] if !ok { t.Errorf("openapi.yaml has no components.schemas.%s", name) continue } compareSchema(t, name, flatten(s, doc.Components.Schemas), reflect.TypeOf(v)) } } type schemaDoc struct { Ref string `json:"$ref"` AllOf []schemaDoc `json:"allOf"` Type any `json:"type"` Required []string `json:"required"` Properties map[string]schemaDoc `json:"properties"` Items *schemaDoc `json:"items"` } // flatten resolves a top-level $ref and merges allOf parts into one object // schema, which is how a Go struct embedding another one is documented. func flatten(s schemaDoc, all map[string]schemaDoc) schemaDoc { if s.Ref != "" { s = all[strings.TrimPrefix(s.Ref, "#/components/schemas/")] } if len(s.AllOf) == 0 { return s } out := schemaDoc{Properties: map[string]schemaDoc{}} for _, part := range append(s.AllOf, schemaDoc{Required: s.Required, Properties: s.Properties}) { part = flatten(part, all) out.Required = append(out.Required, part.Required...) for k, v := range part.Properties { out.Properties[k] = v } } return out } type wireField struct { omitempty bool typ reflect.Type } // wireFields lists the JSON fields encoding/json emits for t, following // embedded structs the way the encoder does. func wireFields(t reflect.Type) map[string]wireField { out := map[string]wireField{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) tag := f.Tag.Get("json") if tag == "-" { continue } name, opts, _ := strings.Cut(tag, ",") if f.Anonymous && name == "" { for k, v := range wireFields(f.Type) { out[k] = v } continue } if !f.IsExported() { continue } if name == "" { name = f.Name } out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type} } return out } func structOf(t reflect.Type) (reflect.Type, bool) { for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { t = t.Elem() } return t, t.Kind() == reflect.Struct && t.PkgPath() != "time" } func compareSchema(t *testing.T, path string, s schemaDoc, typ reflect.Type) { t.Helper() fields := wireFields(typ) var missing, extra, notRequired, wronglyRequired []string for name, f := range fields { if _, ok := s.Properties[name]; !ok { missing = append(missing, name) } if !f.omitempty && !contains(s.Required, name) { notRequired = append(notRequired, name) } } for name := range s.Properties { if _, ok := fields[name]; !ok { extra = append(extra, name) } } for _, name := range s.Required { if f, ok := fields[name]; ok && f.omitempty { wronglyRequired = append(wronglyRequired, name) } } report := func(what string, names []string) { if len(names) > 0 { sort.Strings(names) t.Errorf("%s: %s: %s", path, what, strings.Join(names, ", ")) } } report("sent by Go but missing from openapi.yaml", missing) report("documented but never sent by Go", extra) report("always sent but not in required", notRequired) report("required but omitted when empty", wronglyRequired) // Nested objects (an object property, or an array of objects) are held to // the same rule when the schema spells their properties out. for name, f := range fields { p, ok := s.Properties[name] if !ok { continue } inner, isStruct := structOf(f.typ) if !isStruct { continue } switch { case len(p.Properties) > 0: compareSchema(t, path+"."+name, p, inner) case p.Items != nil && len(p.Items.Properties) > 0: compareSchema(t, path+"."+name+"[]", *p.Items, inner) } } } func contains(xs []string, x string) bool { for _, v := range xs { if v == x { return true } } return false } Loading
.github/workflows/ci.yml +29 −0 Changes for .github/workflows/ci.yml: 29 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -165,9 +165,38 @@ jobs: - run: npm test working-directory: panel # `tsc -b`: tsconfig.json is a solution file (files: [] plus references), so a plain # `tsc --noEmit` checked nothing and passed with type errors in the tree. - run: npm run typecheck working-directory: panel # Rules of hooks and effect dependency lists, with --max-warnings 0. - run: npm run lint working-directory: panel # openapi.gen.ts is generated from docs/openapi.yaml and checked in, so the # compile-time parity in src/lib/types.parity.ts needs no generator in the build; # a schema edit that was not regenerated fails here. - name: openapi.gen.ts matches docs/openapi.yaml working-directory: panel run: | npm run gen:api git diff --exit-code -- src/lib/openapi.gen.ts # Browser smoke over the mock-mode dev server, in the runner's installed Chrome # (playwright.config.ts sets channel: chrome, so nothing is downloaded). - run: npm run test:e2e working-directory: panel - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: name: panel-playwright-report path: | panel/playwright-report panel/test-results retention-days: 7 plugins: runs-on: ubuntu-latest steps: Loading
docs/openapi.yaml +52 −26 Changes for docs/openapi.yaml: 52 added lines, 26 removed lines. Original line number Diff line number Diff line Loading @@ -303,7 +303,7 @@ components: ServerInfo: type: object description: Status projection of one server (internal/api/cluster.go ServerInfo). required: [name, subdomain, phase, ready, playersOnline, playersMax] required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds] properties: name: { type: string } subdomain: { type: string } Loading @@ -311,6 +311,7 @@ components: ready: { type: boolean } autostartPolicy: type: string enum: [ownerOnly, public, allowlist] description: Present only when set; who may wake the server via domain-autostart. desiredState: type: string Loading @@ -333,6 +334,26 @@ components: type: boolean description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. FleetServer: description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). allOf: - $ref: '#/components/schemas/ServerInfo' - type: object properties: owner: type: string description: >- The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. system: type: boolean description: >- True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. MyServerView: type: object description: One row of the caller's server list (internal/api/repo.go MyServerView). Loading @@ -350,6 +371,20 @@ components: format: int32 description: Best-effort from live CRD status; 0 when the cluster is unreachable. playersMax: { type: integer, format: int32 } displayName: type: string description: From live CRD status; omitted when unset or the cluster is unreachable. desiredState: type: string enum: [Running, Stopped] description: Owned rows only, from live CRD status. autostartPolicy: type: string enum: [ownerOnly, public, allowlist] description: Owned rows only, from live CRD status. playerCountUnknown: type: boolean description: Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players. BackupView: type: object Loading Loading @@ -391,6 +426,9 @@ components: enum: [pending, building, succeeded, failed, cancelled] dockerfile: { type: string } context_ref: { type: string } context_digest: type: string description: Lowercase hex sha256 of the context tarball the build was pinned to (the audit record). Omitted when the request named none. base_image: { type: string } requested_by: { type: string } job_name: { type: string } Loading @@ -398,9 +436,9 @@ components: error: { type: string } created_at: { type: string, format: date-time } finished_at: type: [string, 'null'] type: string format: date-time description: Null until the build reaches a terminal status. description: Omitted until the build reaches a terminal status. Image: type: object Loading Loading @@ -430,6 +468,9 @@ components: context_ref: type: string description: Platform-derived pinned build context; not user-supplied. context_sha256: type: string description: Lowercase hex sha256 of the uploaded context tarball; omitted until one is uploaded. Approval must name it. status: type: string enum: [pending_review, approved, rejected] Loading @@ -456,9 +497,9 @@ components: reject_reason: { type: string } created_at: { type: string, format: date-time } reviewed_at: type: [string, 'null'] type: string format: date-time description: Null until an admin approves or rejects. description: Omitted until an admin approves or rejects. UserView: type: object Loading @@ -478,7 +519,7 @@ components: UserDetail: type: object description: Full admin view of one user (internal/api/repo.go UserDetail). required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts] required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at] properties: id: { type: string } username: { type: string } Loading @@ -490,11 +531,12 @@ components: created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: type: [string, 'null'] type: string format: date-time description: Present only when soft-deleted. linked_accounts: type: array description: Omitted when the user has no linked Minecraft account. items: type: object required: [mc_uuid, auth_source, verified_at] Loading Loading @@ -523,8 +565,9 @@ components: created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } revoked_at: type: [string, 'null'] type: string format: date-time description: Present only once the session is revoked. paths: # ----------------------------------------------------------------- health --- Loading Loading @@ -2922,24 +2965,7 @@ paths: properties: servers: type: array items: allOf: - $ref: '#/components/schemas/ServerInfo' - type: object properties: owner: type: string description: >- The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. system: type: boolean description: >- True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. items: { $ref: '#/components/schemas/FleetServer' } '401': $ref: '#/components/responses/Unauthorized' '403': Loading
internal/api/api_test.go +75 −2 Changes for internal/api/api_test.go: 75 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -746,7 +746,9 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error { return nil } func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) { return f.mine[u], nil // Fresh rows per call, as PGRepo scans them: the handler joins live state // into the slice it gets. return append([]MyServerView(nil), f.mine[u]...), nil } func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) { if f.ownersErr != nil { Loading Loading @@ -1535,6 +1537,7 @@ type fakeCluster struct { byName map[string]*ServerInfo bySub map[string]*ServerInfo list []ServerInfo listErr error 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 Loading Loading @@ -1567,7 +1570,12 @@ func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo, } return nil, ErrNotFound } func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil } func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { if c.listErr != nil { return nil, c.listErr } return c.list, nil } func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr } // WorldVolumeExists models the world PVC: present unless the test named the Loading Loading @@ -1860,6 +1868,71 @@ func TestMeIdentity(t *testing.T) { }) } // TestMyServersJoinsLiveState proves /me/servers carries the live CRD fields the // panel renders: every row gets the display name, live phase and counts, and // only the caller's own rows get owner detail (desired state, autostart policy, // an unreadable player count), as the status route withholds them from others. // A cluster read failure keeps the Postgres rows with the cached phase. func TestMyServersJoinsLiveState(t *testing.T) { repo := newFakeRepo() repo.mine = map[string][]MyServerView{"u1": { {Name: "mine", Subdomain: "mine", Owned: true, Phase: "Stopped"}, {Name: "open", Subdomain: "open", Claimable: true, Phase: "Stopped"}, }} cl := newFakeCluster() cl.list = []ServerInfo{ {Name: "mine", DisplayName: "My World", Phase: "Running", DesiredState: "Running", AutostartPolicy: "ownerOnly", PlayersOnline: 2, PlayersMax: 20, PlayerCountUnknown: true}, {Name: "open", DisplayName: "Open World", Phase: "Running", DesiredState: "Running", AutostartPolicy: "public", PlayersMax: 10, PlayerCountUnknown: true}, } api := newTestAPI(repo, cl) api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}} read := func() map[string]map[string]any { t.Helper() w := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) } var body struct { Servers []map[string]any `json:"servers"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } out := map[string]map[string]any{} for _, s := range body.Servers { out[s["name"].(string)] = s } return out } got := read() mine, open := got["mine"], got["open"] for k, want := range map[string]any{"displayName": "My World", "phase": "Running", "desiredState": "Running", "autostartPolicy": "ownerOnly", "playerCountUnknown": true, "playersOnline": float64(2), "playersMax": float64(20)} { if mine[k] != want { t.Errorf("own row %s = %v, want %v", k, mine[k], want) } } if open["displayName"] != "Open World" || open["phase"] != "Running" || open["playersMax"] != float64(10) { t.Errorf("claimable row public fields = %v", open) } for _, k := range []string{"desiredState", "autostartPolicy", "playerCountUnknown"} { if _, ok := open[k]; ok { t.Errorf("claimable row carries owner detail %s = %v", k, open[k]) } } cl.listErr = errors.New("apiserver down") got = read() if got["mine"]["phase"] != "Stopped" || got["mine"]["playersOnline"] != float64(0) { t.Errorf("cluster down: own row = %v, want cached phase and 0 players", got["mine"]) } } // ---- fleet (SysAdmin cockpit read) ---- // TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND Loading
internal/api/handlers_user.go +21 −6 Changes for internal/api/handlers_user.go: 21 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -239,19 +239,34 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } // Player counts are presentational and best-effort, mirroring handleFleet's // The live fields are presentational and best-effort, mirroring handleFleet's // owner join: the list exists for ownership/claim state, so a cluster hiccup // must degrade to 0/0 counts, never 500 the whole list. The CRD status is the // only source of live counts (spec §1) — Postgres never stores them. // must degrade to 0/0 counts and the cached phase, never 500 the whole list. // The CRD status is the only source of live state (spec §1) — Postgres never // stores counts. Owner detail (desired state, autostart policy, whether the // count is readable) joins only onto rows the caller owns; the panel needs // playerCountUnknown there to ask before a stop that may drop players. if infos, err := a.Cluster.ListServers(r.Context()); err == nil { byName := make(map[string]ServerInfo, len(infos)) for _, s := range infos { byName[s.Name] = s } for i := range servers { if info, ok := byName[servers[i].Name]; ok { servers[i].PlayersOnline = info.PlayersOnline servers[i].PlayersMax = info.PlayersMax info, ok := byName[servers[i].Name] if !ok { continue } v := &servers[i] v.PlayersOnline = info.PlayersOnline v.PlayersMax = info.PlayersMax v.DisplayName = info.DisplayName if info.Phase != "" { v.Phase = info.Phase } if v.Owned { v.DesiredState = info.DesiredState v.AutostartPolicy = info.AutostartPolicy v.PlayerCountUnknown = info.PlayerCountUnknown } } } Loading
internal/api/openapi_parity_test.go 0 → 100644 +190 −0 Changes for internal/api/openapi_parity_test.go: 190 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "os" "reflect" "sort" "strings" "testing" "felis.lolicon.best/internal/build" "sigs.k8s.io/yaml" ) // TestOpenAPISchemasMatchWireStructs keeps docs/openapi.yaml honest about the // response bodies. Each named schema is compared with the Go struct the handler // actually encodes: the property set must equal the struct's JSON field set, and // `required` must list exactly the fields that are always on the wire (no // omitempty). The panel's types are checked against the same schemas at compile // time (panel/src/lib/types.parity.ts), so a field added here without the docs // fails in Go, and one added to the docs without the panel fails in tsc. func TestOpenAPISchemasMatchWireStructs(t *testing.T) { raw, err := os.ReadFile("../../docs/openapi.yaml") if err != nil { t.Fatal(err) } var doc struct { Components struct { Schemas map[string]schemaDoc `json:"schemas"` } `json:"components"` } if err := yaml.Unmarshal(raw, &doc); err != nil { t.Fatalf("parse openapi.yaml: %v", err) } pairs := map[string]any{ "ServerInfo": ServerInfo{}, "FleetServer": fleetServerView{}, "MyServerView": MyServerView{}, "BackupView": BackupView{}, "Build": build.Build{}, "Image": build.Image{}, "Submission": submissionView{}, "UserView": UserView{}, "UserDetail": UserDetail{}, "QuotaView": QuotaView{}, "SessionView": SessionView{}, "PasskeyCredential": passkeyCredentialView{}, "UpdateWindow": updateWindow{}, "DBBackupStatus": dbBackupView{}, } for name, v := range pairs { s, ok := doc.Components.Schemas[name] if !ok { t.Errorf("openapi.yaml has no components.schemas.%s", name) continue } compareSchema(t, name, flatten(s, doc.Components.Schemas), reflect.TypeOf(v)) } } type schemaDoc struct { Ref string `json:"$ref"` AllOf []schemaDoc `json:"allOf"` Type any `json:"type"` Required []string `json:"required"` Properties map[string]schemaDoc `json:"properties"` Items *schemaDoc `json:"items"` } // flatten resolves a top-level $ref and merges allOf parts into one object // schema, which is how a Go struct embedding another one is documented. func flatten(s schemaDoc, all map[string]schemaDoc) schemaDoc { if s.Ref != "" { s = all[strings.TrimPrefix(s.Ref, "#/components/schemas/")] } if len(s.AllOf) == 0 { return s } out := schemaDoc{Properties: map[string]schemaDoc{}} for _, part := range append(s.AllOf, schemaDoc{Required: s.Required, Properties: s.Properties}) { part = flatten(part, all) out.Required = append(out.Required, part.Required...) for k, v := range part.Properties { out.Properties[k] = v } } return out } type wireField struct { omitempty bool typ reflect.Type } // wireFields lists the JSON fields encoding/json emits for t, following // embedded structs the way the encoder does. func wireFields(t reflect.Type) map[string]wireField { out := map[string]wireField{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) tag := f.Tag.Get("json") if tag == "-" { continue } name, opts, _ := strings.Cut(tag, ",") if f.Anonymous && name == "" { for k, v := range wireFields(f.Type) { out[k] = v } continue } if !f.IsExported() { continue } if name == "" { name = f.Name } out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type} } return out } func structOf(t reflect.Type) (reflect.Type, bool) { for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { t = t.Elem() } return t, t.Kind() == reflect.Struct && t.PkgPath() != "time" } func compareSchema(t *testing.T, path string, s schemaDoc, typ reflect.Type) { t.Helper() fields := wireFields(typ) var missing, extra, notRequired, wronglyRequired []string for name, f := range fields { if _, ok := s.Properties[name]; !ok { missing = append(missing, name) } if !f.omitempty && !contains(s.Required, name) { notRequired = append(notRequired, name) } } for name := range s.Properties { if _, ok := fields[name]; !ok { extra = append(extra, name) } } for _, name := range s.Required { if f, ok := fields[name]; ok && f.omitempty { wronglyRequired = append(wronglyRequired, name) } } report := func(what string, names []string) { if len(names) > 0 { sort.Strings(names) t.Errorf("%s: %s: %s", path, what, strings.Join(names, ", ")) } } report("sent by Go but missing from openapi.yaml", missing) report("documented but never sent by Go", extra) report("always sent but not in required", notRequired) report("required but omitted when empty", wronglyRequired) // Nested objects (an object property, or an array of objects) are held to // the same rule when the schema spells their properties out. for name, f := range fields { p, ok := s.Properties[name] if !ok { continue } inner, isStruct := structOf(f.typ) if !isStruct { continue } switch { case len(p.Properties) > 0: compareSchema(t, path+"."+name, p, inner) case p.Items != nil && len(p.Items.Properties) > 0: compareSchema(t, path+"."+name+"[]", *p.Items, inner) } } } func contains(xs []string, x string) bool { for _, v := range xs { if v == x { return true } } return false }