Unverified Commit 2f99874d authored by Lemon-miaow's avatar Lemon-miaow
Browse files

test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认

parent ea425cff
Loading
Loading
Loading
Loading
+29 −0
Changes for .github/workflows/ci.yml: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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:
+52 −26
Changes for docs/openapi.yaml: 52 added lines, 26 removed lines.
Original line number Diff line number Diff line
@@ -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 }
@@ -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
@@ -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).
@@ -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
@@ -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 }
@@ -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
@@ -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]
@@ -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
@@ -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 }
@@ -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]
@@ -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 ---
@@ -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':
+75 −2
Changes for internal/api/api_test.go: 75 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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 {
@@ -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
@@ -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
@@ -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
+21 −6
Changes for internal/api/handlers_user.go: 21 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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
			}
		}
	}
+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