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

This commit is contained in:
Lemon-miaow committed 2026-09-25 18:24:47 +08:00
1 parent 1054e62fa9
commit e7326e6315
11 files changed
+937 -28

No files matched your search

+174 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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})
}
+3
View File
@@ -270,6 +270,9 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
}
}
}
if servers == nil {
servers = []MyServerView{} // no servers is [], never null
}
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
}
+21 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"fmt"
"os"
"testing"
)
@@ -11,5 +12,24 @@ import (
// changes the day someone buys the name and which CI may not reach at all.
func TestMain(m *testing.M) {
mojangProfileAPI = "http://127.0.0.1:1/"
os.Exit(m.Run())
code := m.Run()
// Every exchange the handler tests made is then held to docs/openapi.yaml
// (openapi_contract_test.go).
if code == 0 {
contractCalls.Lock()
violations, err := checkContract("../../docs/openapi.yaml", contractCalls.list)
contractCalls.Unlock()
if err != nil {
fmt.Fprintln(os.Stderr, "openapi contract:", err)
code = 1
}
for _, v := range violations {
fmt.Fprintln(os.Stderr, "openapi contract:", v)
}
if len(violations) > 0 {
fmt.Fprintf(os.Stderr, "FAIL: %d exchange(s) disagree with docs/openapi.yaml\n", len(violations))
code = 1
}
}
os.Exit(code)
}
+511
View File
@@ -0,0 +1,511 @@
package api
import (
"encoding/json"
"fmt"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"testing"
"sigs.k8s.io/yaml"
)
// The route-table parity tests (openapi_test.go) prove docs/openapi.yaml names the
// right {method, path, face, tier, setup}; the struct parity test proves the named
// schemas match their Go structs. Neither sees what a handler actually answers. This
// file does: every request a handler test sends through do() is kept, and once the
// whole package has run TestMain holds each exchange to the document —
//
// - the operation must list the status that came back (or a default / 4XX / 5XX);
// - a JSON body must fit the schema documented for that status, and a response
// object may carry only the properties its schema names;
// - the JSON request behind a 2xx must fit the documented requestBody.
//
// Requests to a path the document does not have are left to the route parity test.
type contractCall struct {
method, path string
reqCT string
reqBody []byte
status int
respCT string
respBody []byte
at string // the test line that sent it
}
var contractCalls struct {
sync.Mutex
list []contractCall
}
// recordContract keeps one do() exchange for checkContract.
func recordContract(r *http.Request, body string, w *httptest.ResponseRecorder) {
at := ""
if _, file, line, ok := runtime.Caller(2); ok {
at = fmt.Sprintf("%s:%d", file[strings.LastIndex(file, "/")+1:], line)
}
contractCalls.Lock()
defer contractCalls.Unlock()
contractCalls.list = append(contractCalls.list, contractCall{
method: r.Method, path: r.URL.Path,
reqCT: r.Header.Get("Content-Type"), reqBody: []byte(body),
status: w.Code, respCT: w.Header().Get("Content-Type"), respBody: w.Body.Bytes(),
at: at,
})
}
type contractDoc struct {
// Common lists the answers any operation can give under a condition, declared
// once at the top of the document instead of under every operation.
Common []contractCommon `json:"x-felis-common-responses"`
Paths map[string]map[string]any `json:"paths"`
Components struct {
Schemas map[string]any `json:"schemas"`
Responses map[string]any `json:"responses"`
RequestBodies map[string]any `json:"requestBodies"`
} `json:"components"`
}
type contractCommon struct {
Status int `json:"status"`
// When is any, internal (an operation the internal face serves), session (one
// that takes the session cookie), setup-locked (a session one a setup-lockdown
// session may not use) or json-body (one that takes a JSON requestBody).
When string `json:"when"`
Code string `json:"code"`
Response map[string]any `json:"response"`
}
type contractRoute struct {
template string
re *regexp.Regexp
literals int
}
// checkContract returns one line per exchange that disagrees with docs/openapi.yaml.
func checkContract(path string, calls []contractCall) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc contractDoc
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
routes := make([]contractRoute, 0, len(doc.Paths))
for tmpl := range doc.Paths {
routes = append(routes, compileContractRoute(tmpl))
}
// The most literal template wins: /servers/limbo is not /servers/{name}.
sort.Slice(routes, func(i, j int) bool {
if routes[i].literals != routes[j].literals {
return routes[i].literals > routes[j].literals
}
return routes[i].template < routes[j].template
})
var out []string
seen := map[string]bool{}
report := func(c contractCall, format string, args ...any) {
line := fmt.Sprintf("%s %s → %d (%s): %s", c.method, c.path, c.status, c.at, fmt.Sprintf(format, args...))
if !seen[line] {
seen[line] = true
out = append(out, line)
}
}
for _, c := range calls {
tmpl := ""
for _, r := range routes {
if r.re.MatchString(c.path) {
tmpl = r.template
break
}
}
if tmpl == "" {
continue
}
op, ok := doc.Paths[tmpl][strings.ToLower(c.method)].(map[string]any)
if !ok {
continue
}
// A path asked on the face that does not serve it: the route parity test
// owns which face serves what.
if c.status == http.StatusNotFound && contractErrorMessage(c.respBody) == "no such endpoint" {
continue
}
v := &contractValidator{doc: &doc}
resp, ok := contractResponse(op, c.status)
if !ok {
resp, ok = contractCommonResponse(&doc, op, c)
}
if !ok {
report(c, "status %d is not documented for %s %s", c.status, c.method, tmpl)
continue
}
resp = v.deref(resp)
if schema, ok := jsonSchemaOf(resp); ok && isJSON(c.respCT) && len(c.respBody) > 0 {
var body any
if err := json.Unmarshal(c.respBody, &body); err != nil {
report(c, "response is not JSON: %v", err)
} else {
for _, e := range v.check(schema, body, "response", true) {
report(c, "%s", e)
}
}
}
if c.status/100 == 2 && isJSON(c.reqCT) && len(c.reqBody) > 0 {
rb, ok := op["requestBody"].(map[string]any)
if !ok && c.method == http.MethodGet {
continue // a body on a GET is ignored, whatever it holds
}
if !ok {
report(c, "a JSON request body was accepted but %s %s documents none", c.method, tmpl)
continue
}
if schema, ok := jsonSchemaOf(v.deref(rb)); ok {
var body any
if err := json.Unmarshal(c.reqBody, &body); err == nil {
for _, e := range v.check(schema, body, "request", false) {
report(c, "%s", e)
}
}
}
}
}
sort.Strings(out)
return out, nil
}
func compileContractRoute(tmpl string) contractRoute {
var b strings.Builder
b.WriteString("^")
literals := 0
for _, seg := range strings.Split(strings.TrimPrefix(tmpl, "/"), "/") {
b.WriteString("/")
if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") {
b.WriteString(`[^/]+`)
continue
}
literals++
b.WriteString(regexp.QuoteMeta(seg))
}
b.WriteString("$")
return contractRoute{template: tmpl, re: regexp.MustCompile(b.String()), literals: literals}
}
// contractResponse finds the documented response for a status: the exact code, then
// its class (4XX), then default.
func contractResponse(op map[string]any, status int) (map[string]any, bool) {
responses, _ := op["responses"].(map[string]any)
for _, k := range []string{strconv.Itoa(status), fmt.Sprintf("%dXX", status/100), "default"} {
if r, ok := responses[k].(map[string]any); ok {
return r, true
}
}
return nil, false
}
// contractCommonResponse finds a common answer whose condition the operation meets
// and whose error code, when it names one, is the one that came back.
func contractCommonResponse(doc *contractDoc, op map[string]any, c contractCall) (map[string]any, bool) {
for _, common := range doc.Common {
if common.Status != c.status || (common.Code != "" && contractErrorCode(c.respBody) != common.Code) {
continue
}
if contractOpMeets(doc, op, common.When) {
return common.Response, true
}
}
return nil, false
}
func contractOpMeets(doc *contractDoc, op map[string]any, when string) bool {
switch when {
case "any":
return true
case "internal":
faces, _ := op["x-felis-face"].([]any)
return containsJSON(faces, "internal")
case "session":
security, _ := op["security"].([]any)
for _, req := range security {
if m, ok := req.(map[string]any); ok {
if _, ok := m["sessionCookie"]; ok {
return true
}
}
}
return false
case "setup-locked":
allowed, _ := op["x-felis-setup-allowed"].(bool)
return !allowed && contractOpMeets(doc, op, "session")
case "json-body":
rb, _ := op["requestBody"].(map[string]any)
v := &contractValidator{doc: doc}
_, ok := jsonSchemaOf(v.deref(rb))
return rb != nil && ok
}
return false
}
func contractErrorCode(body []byte) string {
var e struct {
Error struct{ Code, Message string } `json:"error"`
}
_ = json.Unmarshal(body, &e)
return e.Error.Code
}
func contractErrorMessage(body []byte) string {
var e struct {
Error struct{ Code, Message string } `json:"error"`
}
_ = json.Unmarshal(body, &e)
return e.Error.Message
}
func jsonSchemaOf(r map[string]any) (map[string]any, bool) {
content, _ := r["content"].(map[string]any)
media, _ := content["application/json"].(map[string]any)
schema, ok := media["schema"].(map[string]any)
return schema, ok
}
func isJSON(ct string) bool {
mt, _, err := mime.ParseMediaType(ct)
return err == nil && mt == "application/json"
}
type contractValidator struct{ doc *contractDoc }
// deref follows a local $ref (#/components/<kind>/<name>) until it reaches the object.
func (v *contractValidator) deref(n map[string]any) map[string]any {
for i := 0; i < 16; i++ {
ref, ok := n["$ref"].(string)
if !ok {
return n
}
parts := strings.Split(strings.TrimPrefix(ref, "#/components/"), "/")
if len(parts) != 2 {
return map[string]any{"x-unresolved": ref}
}
name, _ := url.PathUnescape(parts[1])
var table map[string]any
switch parts[0] {
case "schemas":
table = v.doc.Components.Schemas
case "responses":
table = v.doc.Components.Responses
case "requestBodies":
table = v.doc.Components.RequestBodies
}
next, ok := table[name].(map[string]any)
if !ok {
return map[string]any{"x-unresolved": ref}
}
n = next
}
return n
}
// check returns where value leaves schema. strict (responses) also refuses object
// properties the schema does not name, unless it allows additional ones.
func (v *contractValidator) check(schema map[string]any, value any, at string, strict bool) []string {
schema = v.deref(schema)
if ref, ok := schema["x-unresolved"]; ok {
return []string{fmt.Sprintf("%s: unresolved $ref %v", at, ref)}
}
var errs []string
if all, ok := schema["allOf"].([]any); ok {
for _, s := range all {
if m, ok := s.(map[string]any); ok {
errs = append(errs, v.check(m, value, at, false)...)
}
}
}
for _, key := range []string{"oneOf", "anyOf"} {
alts, ok := schema[key].([]any)
if !ok {
continue
}
matched := false
for _, s := range alts {
if m, ok := s.(map[string]any); ok && len(v.check(m, value, at, strict)) == 0 {
matched = true
break
}
}
if !matched {
errs = append(errs, fmt.Sprintf("%s: matches none of %s", at, key))
}
}
if types := schemaTypes(schema); len(types) > 0 && !types[jsonKind(value)] &&
!(jsonKind(value) == "integer" && types["number"]) {
return append(errs, fmt.Sprintf("%s: is %s, documented as %s", at, jsonKind(value), typeList(types)))
}
if enum, ok := schema["enum"].([]any); ok && !containsJSON(enum, value) {
errs = append(errs, fmt.Sprintf("%s: %v is not one of %v", at, value, enum))
}
if c, ok := schema["const"]; ok && !reflect.DeepEqual(c, value) {
errs = append(errs, fmt.Sprintf("%s: %v is not the documented %v", at, value, c))
}
switch val := value.(type) {
case map[string]any:
props, _ := schema["properties"].(map[string]any)
req, _ := schema["required"].([]any)
for _, r := range req {
if name, _ := r.(string); name != "" {
if _, ok := val[name]; !ok {
errs = append(errs, fmt.Sprintf("%s: required property %q is missing", at, name))
}
}
}
extra, hasExtra := schema["additionalProperties"]
keys := make([]string, 0, len(val))
for k := range val {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if ps, ok := props[k].(map[string]any); ok {
errs = append(errs, v.check(ps, val[k], at+"."+k, strict)...)
continue
}
switch e := extra.(type) {
case map[string]any:
errs = append(errs, v.check(e, val[k], at+"."+k, strict)...)
case bool:
if !e {
errs = append(errs, fmt.Sprintf("%s: property %q is not documented", at, k))
}
default:
if strict && !hasExtra && props != nil {
errs = append(errs, fmt.Sprintf("%s: property %q is not documented", at, k))
}
}
}
case []any:
if items, ok := schema["items"].(map[string]any); ok {
for i, item := range val {
errs = append(errs, v.check(items, item, fmt.Sprintf("%s[%d]", at, i), strict)...)
}
}
}
return errs
}
func schemaTypes(s map[string]any) map[string]bool {
out := map[string]bool{}
switch t := s["type"].(type) {
case string:
out[t] = true
case []any:
for _, x := range t {
if name, ok := x.(string); ok {
out[name] = true
}
}
}
if n, _ := s["nullable"].(bool); n && len(out) > 0 {
out["null"] = true
}
return out
}
func typeList(types map[string]bool) string {
names := make([]string, 0, len(types))
for t := range types {
names = append(names, t)
}
sort.Strings(names)
return strings.Join(names, "|")
}
func jsonKind(v any) string {
switch x := v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case float64:
if x == float64(int64(x)) {
return "integer"
}
return "number"
case string:
return "string"
case []any:
return "array"
case map[string]any:
return "object"
}
return fmt.Sprintf("%T", v)
}
func containsJSON(list []any, v any) bool {
for _, x := range list {
if reflect.DeepEqual(x, v) {
return true
}
}
return false
}
// The checker is itself held to known drifts: each synthetic exchange below either
// disagrees with docs/openapi.yaml in exactly the way named, or agrees with it.
func TestContractCheckerCatchesDrift(t *testing.T) {
jsonCT := "application/json"
errBody := func(code, msg string) []byte {
return []byte(`{"error":{"code":"` + code + `","message":"` + msg + `","request_id":"r"}}`)
}
calls := []contractCall{
// An empty fleet sent as null.
{method: "GET", path: "/api/v1/servers", status: 200, respCT: jsonCT, respBody: []byte(`{"servers":null}`), at: "a"},
// A property the schema does not name.
{method: "GET", path: "/api/v1/servers", status: 200, respCT: jsonCT, respBody: []byte(`{"servers":[],"extra":1}`), at: "b"},
// A status the operation does not list.
{method: "GET", path: "/api/v1/me", status: 418, respCT: jsonCT, respBody: errBody("teapot", "no"), at: "c"},
// wrong_caller is an internal-face answer; /me is external only.
{method: "GET", path: "/api/v1/me", status: 403, respCT: jsonCT, respBody: errBody("wrong_caller", "no"), at: "d"},
// ...and on an internal operation it is a common answer.
{method: "GET", path: "/api/v1/servers", status: 403, respCT: jsonCT, respBody: errBody("wrong_caller", "no"), at: "e"},
// /me stays open during the setup lockdown, so setup_required is not its answer...
{method: "GET", path: "/api/v1/me", status: 403, respCT: jsonCT, respBody: errBody("setup_required", "no"), at: "f"},
// ...while a session operation outside the allow-list may give it.
{method: "GET", path: "/api/v1/servers/survival/files", status: 403, respCT: jsonCT, respBody: errBody("setup_required", "no"), at: "g"},
// A path this face does not serve belongs to the route parity test.
{method: "GET", path: "/api/v1/me", status: 404, respCT: jsonCT, respBody: errBody("not_found", "no such endpoint"), at: "h"},
// The request behind a 2xx must fit the requestBody.
{method: "POST", path: "/api/v1/account/link/verify", reqCT: jsonCT, reqBody: []byte(`{"code":5}`),
status: 200, respCT: jsonCT, respBody: []byte(`{"linked":true,"mc_uuid":"u","auth_source":"mojang"}`), at: "i"},
// A response field outside its enum.
{method: "POST", path: "/api/v1/account/link/verify", reqCT: jsonCT, reqBody: []byte(`{"code":"ABC"}`),
status: 200, respCT: jsonCT, respBody: []byte(`{"linked":true,"mc_uuid":"u","auth_source":""}`), at: "j"},
}
got, err := checkContract("../../docs/openapi.yaml", calls)
if err != nil {
t.Fatal(err)
}
want := []string{
`GET /api/v1/me → 403 (d): status 403 is not documented for GET /api/v1/me`,
`GET /api/v1/me → 403 (f): status 403 is not documented for GET /api/v1/me`,
`GET /api/v1/me → 418 (c): status 418 is not documented for GET /api/v1/me`,
`GET /api/v1/servers → 200 (a): response.servers: is null, documented as array`,
`GET /api/v1/servers → 200 (b): response: property "extra" is not documented`,
`POST /api/v1/account/link/verify → 200 (i): request.code: is integer, documented as string`,
`POST /api/v1/account/link/verify → 200 (j): response.auth_source: is not one of [mojang thirdparty]`,
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("violations:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
}
+12 -4
View File
@@ -43,6 +43,8 @@ type oasOp struct {
Faces []string `json:"x-felis-face"`
Tier string `json:"x-felis-tier"`
Callers []string `json:"x-felis-callers"`
// Setup marks an operation a setup-lockdown session may still use.
Setup bool `json:"x-felis-setup-allowed"`
}
// oasFacet is the classification of one {method, path}: which face(s) serve it
@@ -52,6 +54,8 @@ type oasFacet struct {
tier string
// callers is the internal route's caller set, empty elsewhere.
callers map[string]bool
// setup is the route's SetupAllowed: reachable during the setup lockdown.
setup bool
}
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
@@ -83,6 +87,9 @@ func TestOpenAPIMatchesServedRoutes(t *testing.T) {
if s.tier != d.tier {
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier)
}
if s.setup != d.setup {
t.Errorf("%s: x-felis-setup-allowed mismatch — served %v, documented %v", key, s.setup, d.setup)
}
if !oasSameSet(s.callers, d.callers) {
t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v",
key, oasSortedKeys(s.callers), oasSortedKeys(d.callers))
@@ -99,7 +106,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
t.Helper()
a := &API{}
out := map[string]oasFacet{}
add := func(method, pattern, face, tier string, callers ...Caller) {
add := func(method, pattern, face, tier string, setup bool, callers ...Caller) {
key := method + " " + pattern
f, ok := out[key]
if !ok {
@@ -113,6 +120,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
t.Fatalf("%s: route table assigns conflicting tiers %q and %q", key, f.tier, tier)
}
f.tier = tier
f.setup = f.setup || setup
out[key] = f
}
for _, rt := range a.internalAPIRoutes() {
@@ -120,7 +128,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
if rt.Public {
tier = "public"
}
add(rt.Method, rt.Pattern, "internal", tier, rt.Callers...)
add(rt.Method, rt.Pattern, "internal", tier, rt.SetupAllowed, rt.Callers...)
}
for _, rt := range a.externalAPIRoutes() {
var tier string
@@ -134,7 +142,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
default:
tier = "app"
}
add(rt.Method, rt.Pattern, "external", tier)
add(rt.Method, rt.Pattern, "external", tier, rt.SetupAllowed)
}
return out
}
@@ -186,7 +194,7 @@ func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
for _, c := range op.Callers {
callers[c] = true
}
out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers}
out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers, setup: op.Setup}
}
}
return out
+5 -5
View File
@@ -409,7 +409,7 @@ func TestMySubmissionsCarriesBuildOutcome(t *testing.T) {
// the whole list; any other lookup failure must surface, never be swallowed.
func TestMySubmissionsBuildLookupSemantics(t *testing.T) {
// Missing row (ErrNotFound): 200 with no build fields.
fs := &fakeSubmissions{byResult: []submit.Submission{{ID: "sub-1", BuildID: "bld-gone"}}}
fs := &fakeSubmissions{byResult: []submit.Submission{{ID: "sub-1", Status: submit.StatusApproved, BuildID: "bld-gone"}}}
api := appSubAPI(fs)
api.Builder = &fakeBuilder{}
w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil)
@@ -486,9 +486,9 @@ func TestSubmissionListsForwardThePage(t *testing.T) {
// with no linked build asks nothing.
func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) {
fs := &fakeSubmissions{listed: []submit.Submission{
{ID: "sub-1", BuildID: "bld-1"},
{ID: "sub-2"},
{ID: "sub-3", BuildID: "bld-3"},
{ID: "sub-1", Status: submit.StatusApproved, BuildID: "bld-1"},
{ID: "sub-2", Status: submit.StatusPendingReview},
{ID: "sub-3", Status: submit.StatusApproved, BuildID: "bld-3"},
}}
fb := &fakeBuilder{getBuilds: map[string]*build.Build{
"bld-1": {ID: "bld-1", Status: build.StatusSucceeded},
@@ -518,7 +518,7 @@ func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) {
t.Fatalf("outcomes = %+v", got.Submissions)
}
fs.listed = []submit.Submission{{ID: "sub-2"}}
fs.listed = []submit.Submission{{ID: "sub-2", Status: submit.StatusPendingReview}}
fb.getManyIDs = nil
if w := do(api.ExternalHandler(), "GET", "/api/v1/submissions", "", nil); w.Code != http.StatusOK {
t.Fatalf("unlinked page: code = %d", w.Code)
+198 -6
View File
@@ -2396,6 +2396,69 @@ export interface components {
};
content?: never;
};
/** @description An unexpected failure (internal); the details are in the server log under the request id. */
InternalError: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description A genuine service token for a caller this operation does not serve (wrong_caller). */
WrongCaller: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description A session of a player (not admin or owner) on the operator console host, refused before any handler (forbidden). */
StaffOnlyHost: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @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). */
SetupRequired: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The JSON body is over 1 MiB (too_large). */
TooLarge: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description A body sent with a Content-Type other than application/json (unsupported_media_type). */
UnsupportedMediaType: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @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). */
InsufficientStorage: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Malformed or invalid request (validation, bad body, unknown field). */
BadRequest: {
headers: {
@@ -2850,6 +2913,15 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description The node is at its running-server cap (at_capacity). */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
internalStatus: {
@@ -3101,6 +3173,15 @@ export interface operations {
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
/** @description The linked account holds a staff role and is never reclaimed (protected_admin). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
checkUsernameBlacklist: {
@@ -3403,6 +3484,15 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description The node is at its running-server cap (at_capacity). */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
stop: {
@@ -3559,6 +3649,15 @@ export interface operations {
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server not running. */
409: {
headers: {
@@ -3568,6 +3667,15 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description This session already holds as many console streams as it may (too_many_streams). */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
@@ -4119,7 +4227,7 @@ export interface operations {
"application/json": {
user_id: string;
/** @enum {string} */
role: "user" | "admin";
role: "owner" | "admin" | "user";
};
};
};
@@ -4266,7 +4374,7 @@ export interface operations {
"application/json": {
user_id: string;
/** @enum {string} */
role: "user" | "admin";
role: "owner" | "admin" | "user";
};
};
};
@@ -4405,7 +4513,7 @@ export interface operations {
"application/json": {
user_id: string;
/** @enum {string} */
role: "user" | "admin";
role: "owner" | "admin" | "user";
};
};
};
@@ -4566,7 +4674,7 @@ export interface operations {
"application/json": {
user_id: string;
/** @enum {string} */
role: "user" | "admin";
role: "owner" | "admin" | "user";
};
};
};
@@ -4625,7 +4733,7 @@ export interface operations {
user_id: string;
username: string;
/** @enum {string} */
role: "user" | "admin";
role: "owner" | "admin" | "user";
email: string;
email_verified: boolean;
has_passkey: boolean;
@@ -4682,7 +4790,7 @@ export interface operations {
user_id: string;
username: string;
/** @enum {string} */
role: "user" | "admin";
role: "owner" | "admin" | "user";
email: string;
email_verified: boolean;
has_passkey: boolean;
@@ -4814,6 +4922,15 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
/** @description The account settings could not be read (auth_unavailable). */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
myServers: {
@@ -5003,6 +5120,15 @@ export interface operations {
};
};
};
/** @description Malformed server name (bad_name) or request body. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description No matching backup. */
@@ -5050,6 +5176,15 @@ export interface operations {
};
};
};
/** @description Malformed server name (bad_name). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server. */
@@ -5070,7 +5205,17 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description Another backup of this server started within the cooldown (backup_cooldown). */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
507: components["responses"]["InsufficientStorage"];
};
};
listServerJobs: {
@@ -5289,6 +5434,7 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
507: components["responses"]["InsufficientStorage"];
};
};
writeServerFile: {
@@ -5643,6 +5789,15 @@ export interface operations {
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown user. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
setQuotas: {
@@ -5677,6 +5832,15 @@ export interface operations {
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown user. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listUserSessions: {
@@ -5837,6 +6001,15 @@ export interface operations {
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown user. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description UUID is already linked to a different user. */
409: {
headers: {
@@ -6058,6 +6231,15 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
/** @description Another account already proved this address (email_taken); the code is not consumed. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */
429: {
headers: {
@@ -7230,6 +7412,15 @@ export interface operations {
"text/event-stream": string;
};
};
/** @description Malformed build id (bad_request). The id becomes a label-selector value, so it is refused before any lookup. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
@@ -7405,6 +7596,7 @@ export interface operations {
"application/json": components["schemas"]["Submission"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];