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

test(api): 处理器测试的每次请求在包结束后对照 openapi 校验状态码、响应与 2xx 请求体,SetupAllowed 纳入一致性比对,补齐漏记的状态码并修正空列表回 null

parent 1054e62f
Loading
Loading
Loading
Loading
+174 −9
Changes for docs/openapi.yaml: 174 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -12,15 +12,52 @@
#     from (internalAPIRoutes / externalAPIRoutes in internal/api/api.go). A route
#     added, removed, re-faced, or re-tiered without updating this file fails
#     `go test ./...`. So path, method, face and tier are as trustworthy as the code.
#   * The request/response BODY schemas below are hand-maintained from the Go
#     handler types and are NOT yet schema-validated against live traffic. Treat
#     them as documentation (SHAPE-ASSERTED), not as a contract test.
#   * Which operations a setup-lockdown session may still use (x-felis-setup-allowed)
#     is checked the same way against the SetupAllowed flag in those tables.
#   * The named response schemas are compared field by field with the Go structs
#     the handlers encode (internal/api/openapi_parity_test.go).
#   * Every request the handler tests send is held to this file once the package
#     has run (internal/api/openapi_contract_test.go): the operation (or
#     x-felis-common-responses) must list the status that came back, a JSON response
#     must fit the schema for that status and carry no property it does not name,
#     and the JSON request behind a 2xx must fit the requestBody. Statuses and
#     bodies no test reaches are still hand-maintained.
#
# The deployment zone (RootDomain, spec §2) never appears here — `example.test`
# is a placeholder, per the no-hardcoded-domain red line.

openapi: 3.1.0

# Answers any operation can give under the stated condition, declared once here
# instead of under every operation. when: any | internal (served on the internal
# face) | session (takes the session cookie) | setup-locked (takes the session
# cookie and is not x-felis-setup-allowed) | json-body (takes a JSON requestBody).
# code, when set, is the error code that answer carries.
x-felis-common-responses:
  - status: 500
    when: any
    response: { $ref: '#/components/responses/InternalError' }
  - status: 403
    when: internal
    code: wrong_caller
    response: { $ref: '#/components/responses/WrongCaller' }
  - status: 403
    when: session
    code: forbidden
    response: { $ref: '#/components/responses/StaffOnlyHost' }
  - status: 403
    when: setup-locked
    code: setup_required
    response: { $ref: '#/components/responses/SetupRequired' }
  - status: 413
    when: json-body
    code: too_large
    response: { $ref: '#/components/responses/TooLarge' }
  - status: 415
    when: json-body
    code: unsupported_media_type
    response: { $ref: '#/components/responses/UnsupportedMediaType' }

info:
  title: felis-api
  version: 4.1.0
@@ -124,6 +161,41 @@ components:
  responses:
    NoContent:
      description: Success, no body.
    InternalError:
      description: An unexpected failure (internal); the details are in the server log under the request id.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    WrongCaller:
      description: A genuine service token for a caller this operation does not serve (wrong_caller).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    StaffOnlyHost:
      description: A session of a player (not admin or owner) on the operator console host, refused before any handler (forbidden).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    SetupRequired:
      description: The session belongs to an account still in first-run setup, which may use only the x-felis-setup-allowed operations until it has a durable sign-in (setup_required).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooLarge:
      description: The JSON body is over 1 MiB (too_large).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: A body sent with a Content-Type other than application/json (unsupported_media_type).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InsufficientStorage:
      description: The store this writes to is full — the backup archive (backup_store_full), the world volume (volume_full) or the upload area (uploads_full).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadRequest:
      description: Malformed or invalid request (validation, bad body, unknown field).
      content:
@@ -976,6 +1048,11 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: The node is at its running-server cap (at_capacity).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/internal/servers/{name}/status:
    get:
@@ -1259,6 +1336,11 @@ paths:
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: The linked account holds a staff role and is never reclaimed (protected_admin).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/internal/player/blacklist/{mc_uuid}:
    get:
@@ -1553,6 +1635,11 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: The node is at its running-server cap (at_capacity).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/servers/{name}/stop:
    post:
@@ -1697,11 +1784,21 @@ paths:
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Unknown server.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Server not running.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: This session already holds as many console streams as it may (too_many_streams).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

@@ -2314,7 +2411,7 @@ paths:
                required: [user_id, role]
                properties:
                  user_id: { type: string }
                  role: { type: string, enum: [user, admin] }
                  role: { type: string, enum: [owner, admin, user] }
        '400':
          description: >-
            Invalid email or missing assertion (bad_request); or the login could not be
@@ -2464,7 +2561,7 @@ paths:
                required: [user_id, role]
                properties:
                  user_id: { type: string }
                  role: { type: string, enum: [user, admin] }
                  role: { type: string, enum: [owner, admin, user] }
        '400':
          description: >-
            Missing login_id or assertion (bad_request); or the login could not be
@@ -2599,7 +2696,7 @@ paths:
                required: [user_id, role]
                properties:
                  user_id: { type: string }
                  role: { type: string, enum: [user, admin] }
                  role: { type: string, enum: [owner, admin, user] }
        '400':
          description: >-
            A valid email and code are required (bad_request); or the code is wrong,
@@ -2756,7 +2853,7 @@ paths:
                required: [user_id, role]
                properties:
                  user_id: { type: string }
                  role: { type: string, enum: [user, admin] }
                  role: { type: string, enum: [owner, admin, user] }
        '400':
          description: >-
            request_id and code are required (bad_request); or the login could not be
@@ -2814,7 +2911,7 @@ paths:
                properties:
                  user_id: { type: string }
                  username: { type: string }
                  role: { type: string, enum: [user, admin] }
                  role: { type: string, enum: [owner, admin, user] }
                  email: { type: string }
                  email_verified: { type: boolean }
                  has_passkey: { type: boolean }
@@ -2851,6 +2948,7 @@ paths:
        API is fenced until setup completes).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -2863,7 +2961,7 @@ paths:
                properties:
                  user_id: { type: string }
                  username: { type: string }
                  role: { type: string, enum: [user, admin] }
                  role: { type: string, enum: [owner, admin, user] }
                  email: { type: string }
                  email_verified: { type: boolean }
                  has_passkey: { type: boolean }
@@ -2970,6 +3068,7 @@ paths:
        access.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -3003,6 +3102,11 @@ paths:
                      nudges unverified accounts through the email-OTP flow.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: The account settings could not be read (auth_unavailable).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/me/servers:
    get:
@@ -3011,6 +3115,7 @@ paths:
      summary: List the servers the caller owns or may claim.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -3227,6 +3332,11 @@ paths:
                  safety_snapshot:
                    type: boolean
                    description: Whether a safety snapshot runs first. False when the request turned it off, or when this install cannot take one.
        '400':
          description: Malformed server name (bad_name) or request body.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
@@ -3272,6 +3382,11 @@ paths:
                properties:
                  name: { type: string }
                  status: { type: string, const: backing_up }
        '400':
          description: Malformed server name (bad_name).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
@@ -3286,8 +3401,15 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Another backup of this server started within the cooldown (backup_cooldown).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
        '507':
          $ref: '#/components/responses/InsufficientStorage'

  # -------------------------------------------------- async job status (app) ---
  /api/v1/servers/{name}/jobs:
@@ -3496,6 +3618,8 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '507':
          $ref: '#/components/responses/InsufficientStorage'
    put:
      tags: [files]
      operationId: writeServerFile
@@ -3817,6 +3941,11 @@ paths:
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Unknown user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      tags: [users]
      operationId: setQuotas
@@ -3849,6 +3978,11 @@ paths:
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Unknown user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/users/{id}/sessions:
    get:
@@ -4010,6 +4144,11 @@ paths:
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Unknown user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: UUID is already linked to a different user.
          content:
@@ -4055,6 +4194,7 @@ paths:
      summary: Report account-link status and in-game instructions (web side, spec §10).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -4077,6 +4217,7 @@ paths:
      summary: Consume an in-game link code and bind the account.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -4128,6 +4269,7 @@ paths:
        session must have reauthed within 5 minutes (403 reauth_required).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -4191,6 +4333,7 @@ paths:
        consumed, or mismatched code is a 400.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -4219,6 +4362,11 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Another account already proved this address (email_taken); the code is not consumed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            Too many incorrect attempts on this code (otp_locked), or the account's
@@ -4241,6 +4389,7 @@ paths:
        must have reauthed within 5 minutes (403 reauth_required).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -4286,6 +4435,7 @@ paths:
        instance.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -4320,6 +4470,7 @@ paths:
        configured.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -4368,6 +4519,7 @@ paths:
        not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -4400,6 +4552,7 @@ paths:
        (403 reauth_required).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      parameters:
        - name: id
@@ -4439,6 +4592,7 @@ paths:
        op-login or a passkey).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -4467,6 +4621,7 @@ paths:
        bound to a fresh reauth-purpose challenge.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '200':
@@ -4494,6 +4649,7 @@ paths:
        clone check (a cloned authenticator is 400 passkey_login_invalid).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -4529,6 +4685,7 @@ paths:
        signing in again (403 staff_reauth).
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      responses:
        '202':
@@ -4579,6 +4736,7 @@ paths:
      summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
      x-felis-face: [external]
      x-felis-tier: app
      x-felis-setup-allowed: true
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
@@ -5412,6 +5570,11 @@ paths:
          content:
            text/event-stream:
              schema: { type: string }
        '400':
          description: Malformed build id (bad_request). The id becomes a label-selector value, so it is refused before any lookup.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
@@ -5595,6 +5758,8 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Submission' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
+1 −0
Changes for internal/api/api_test.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -1893,6 +1893,7 @@ func do(h http.Handler, method, target, body string, headers map[string]string)
	}
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	recordContract(r, body, w)
	return w
}

+3 −3
Changes for internal/api/handlers_account_test.go: 3 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -218,7 +218,7 @@ func TestLinkVerifyRejections(t *testing.T) {
	t.Run("uuid linked to another user -> 409 already_linked", func(t *testing.T) {
		repo := newFakeRepo()
		repo.links[mcUUID] = "someone-else"
		repo.linkCodes["FRESHCOD"] = fakeLinkCode{mcUUID: mcUUID, expiresAt: time.Unix(1_700_000_600, 0)}
		repo.linkCodes["FRESHCOD"] = fakeLinkCode{mcUUID: mcUUID, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)}
		w := do(mk(repo), "POST", "/api/v1/account/link/verify", `{"code":"FRESHCOD"}`, nil)
		if w.Code != http.StatusConflict || decodeErr(t, w) != "already_linked" {
			t.Fatalf("code = %d body %s", w.Code, w.Body.String())
@@ -238,7 +238,7 @@ func TestLinkVerifyIdempotent(t *testing.T) {
	repo := newFakeRepo()
	repo.links[mcUUID] = "u1" // already linked to THIS user
	repo.linked["u1"] = true
	repo.linkCodes["REVERIFYX"] = fakeLinkCode{mcUUID: mcUUID, expiresAt: time.Unix(1_700_000_600, 0)}
	repo.linkCodes["REVERIFYX"] = fakeLinkCode{mcUUID: mcUUID, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)}

	api := newTestAPI(repo, newFakeCluster())
	api.External = staticExternal{p: user}
@@ -276,7 +276,7 @@ func TestLinkVerifyTakesOverDeletedLinkOnly(t *testing.T) {
		t.Fatalf("UserByMCUUID(deleted link) = %v, want ErrNotFound (no in-game standing)", err)
	}

	repo.linkCodes["TAKEOVER"] = fakeLinkCode{mcUUID: mcGone, expiresAt: time.Unix(1_700_000_600, 0)}
	repo.linkCodes["TAKEOVER"] = fakeLinkCode{mcUUID: mcGone, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)}
	api := newTestAPI(repo, newFakeCluster())
	api.External = staticExternal{p: user}
	if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"TAKEOVER"}`, nil); w.Code != http.StatusOK {
+6 −0
Changes for internal/api/handlers_files.go: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -87,6 +87,9 @@ func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) {
		writeFileEditError(w, r, err)
		return
	}
	if entries == nil {
		entries = []fileedit.Entry{} // an empty directory is [], never null
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"path": path, "entries": entries, "truncated": truncated,
	})
@@ -114,6 +117,9 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
		writeFileEditError(w, r, err)
		return
	}
	if content == nil {
		content = []byte{} // an empty file is "", never null
	}
	writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum})
}

+3 −0
Changes for internal/api/handlers_internal.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -43,6 +43,9 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) {
		writeError(w, r, err)
		return
	}
	if servers == nil {
		servers = []ServerInfo{} // an empty fleet is [], never null
	}
	writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
}

Loading