test(api): 处理器测试的每次请求在包结束后对照 openapi 校验状态码、响应与 2xx 请求体,SetupAllowed 纳入一致性比对,补齐漏记的状态码并修正空列表回 null
This commit is contained in:
11 files changed
+937
-28
No files matched your search
+174
-9
@@ -12,15 +12,52 @@
|
|||||||
# from (internalAPIRoutes / externalAPIRoutes in internal/api/api.go). A route
|
# from (internalAPIRoutes / externalAPIRoutes in internal/api/api.go). A route
|
||||||
# added, removed, re-faced, or re-tiered without updating this file fails
|
# 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.
|
# `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
|
# * Which operations a setup-lockdown session may still use (x-felis-setup-allowed)
|
||||||
# handler types and are NOT yet schema-validated against live traffic. Treat
|
# is checked the same way against the SetupAllowed flag in those tables.
|
||||||
# them as documentation (SHAPE-ASSERTED), not as a contract test.
|
# * 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`
|
# The deployment zone (RootDomain, spec §2) never appears here — `example.test`
|
||||||
# is a placeholder, per the no-hardcoded-domain red line.
|
# is a placeholder, per the no-hardcoded-domain red line.
|
||||||
|
|
||||||
openapi: 3.1.0
|
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:
|
info:
|
||||||
title: felis-api
|
title: felis-api
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
@@ -124,6 +161,41 @@ components:
|
|||||||
responses:
|
responses:
|
||||||
NoContent:
|
NoContent:
|
||||||
description: Success, no body.
|
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:
|
BadRequest:
|
||||||
description: Malformed or invalid request (validation, bad body, unknown field).
|
description: Malformed or invalid request (validation, bad body, unknown field).
|
||||||
content:
|
content:
|
||||||
@@ -976,6 +1048,11 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
/api/v1/internal/servers/{name}/status:
|
||||||
get:
|
get:
|
||||||
@@ -1259,6 +1336,11 @@ paths:
|
|||||||
$ref: '#/components/responses/BadRequest'
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$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}:
|
/api/v1/internal/player/blacklist/{mc_uuid}:
|
||||||
get:
|
get:
|
||||||
@@ -1553,6 +1635,11 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
/api/v1/servers/{name}/stop:
|
||||||
post:
|
post:
|
||||||
@@ -1697,11 +1784,21 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
description: Unknown server.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'409':
|
'409':
|
||||||
description: Server not running.
|
description: Server not running.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
@@ -2314,7 +2411,7 @@ paths:
|
|||||||
required: [user_id, role]
|
required: [user_id, role]
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
role: { type: string, enum: [user, admin] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
'400':
|
'400':
|
||||||
description: >-
|
description: >-
|
||||||
Invalid email or missing assertion (bad_request); or the login could not be
|
Invalid email or missing assertion (bad_request); or the login could not be
|
||||||
@@ -2464,7 +2561,7 @@ paths:
|
|||||||
required: [user_id, role]
|
required: [user_id, role]
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
role: { type: string, enum: [user, admin] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
'400':
|
'400':
|
||||||
description: >-
|
description: >-
|
||||||
Missing login_id or assertion (bad_request); or the login could not be
|
Missing login_id or assertion (bad_request); or the login could not be
|
||||||
@@ -2599,7 +2696,7 @@ paths:
|
|||||||
required: [user_id, role]
|
required: [user_id, role]
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
role: { type: string, enum: [user, admin] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
'400':
|
'400':
|
||||||
description: >-
|
description: >-
|
||||||
A valid email and code are required (bad_request); or the code is wrong,
|
A valid email and code are required (bad_request); or the code is wrong,
|
||||||
@@ -2756,7 +2853,7 @@ paths:
|
|||||||
required: [user_id, role]
|
required: [user_id, role]
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
role: { type: string, enum: [user, admin] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
'400':
|
'400':
|
||||||
description: >-
|
description: >-
|
||||||
request_id and code are required (bad_request); or the login could not be
|
request_id and code are required (bad_request); or the login could not be
|
||||||
@@ -2814,7 +2911,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
username: { type: string }
|
username: { type: string }
|
||||||
role: { type: string, enum: [user, admin] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
email: { type: string }
|
email: { type: string }
|
||||||
email_verified: { type: boolean }
|
email_verified: { type: boolean }
|
||||||
has_passkey: { type: boolean }
|
has_passkey: { type: boolean }
|
||||||
@@ -2851,6 +2948,7 @@ paths:
|
|||||||
API is fenced until setup completes).
|
API is fenced until setup completes).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -2863,7 +2961,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
username: { type: string }
|
username: { type: string }
|
||||||
role: { type: string, enum: [user, admin] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
email: { type: string }
|
email: { type: string }
|
||||||
email_verified: { type: boolean }
|
email_verified: { type: boolean }
|
||||||
has_passkey: { type: boolean }
|
has_passkey: { type: boolean }
|
||||||
@@ -2970,6 +3068,7 @@ paths:
|
|||||||
access.
|
access.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -3003,6 +3102,11 @@ paths:
|
|||||||
nudges unverified accounts through the email-OTP flow.
|
nudges unverified accounts through the email-OTP flow.
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$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:
|
/api/v1/me/servers:
|
||||||
get:
|
get:
|
||||||
@@ -3011,6 +3115,7 @@ paths:
|
|||||||
summary: List the servers the caller owns or may claim.
|
summary: List the servers the caller owns or may claim.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -3227,6 +3332,11 @@ paths:
|
|||||||
safety_snapshot:
|
safety_snapshot:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether a safety snapshot runs first. False when the request turned it off, or when this install cannot take one.
|
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':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
@@ -3272,6 +3382,11 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
status: { type: string, const: backing_up }
|
status: { type: string, const: backing_up }
|
||||||
|
'400':
|
||||||
|
description: Malformed server name (bad_name).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
@@ -3286,8 +3401,15 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
'507':
|
||||||
|
$ref: '#/components/responses/InsufficientStorage'
|
||||||
|
|
||||||
# -------------------------------------------------- async job status (app) ---
|
# -------------------------------------------------- async job status (app) ---
|
||||||
/api/v1/servers/{name}/jobs:
|
/api/v1/servers/{name}/jobs:
|
||||||
@@ -3496,6 +3618,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'507':
|
||||||
|
$ref: '#/components/responses/InsufficientStorage'
|
||||||
put:
|
put:
|
||||||
tags: [files]
|
tags: [files]
|
||||||
operationId: writeServerFile
|
operationId: writeServerFile
|
||||||
@@ -3817,6 +3941,11 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
description: Unknown user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
put:
|
put:
|
||||||
tags: [users]
|
tags: [users]
|
||||||
operationId: setQuotas
|
operationId: setQuotas
|
||||||
@@ -3849,6 +3978,11 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
description: Unknown user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
/api/v1/users/{id}/sessions:
|
/api/v1/users/{id}/sessions:
|
||||||
get:
|
get:
|
||||||
@@ -4010,6 +4144,11 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
description: Unknown user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'409':
|
'409':
|
||||||
description: UUID is already linked to a different user.
|
description: UUID is already linked to a different user.
|
||||||
content:
|
content:
|
||||||
@@ -4055,6 +4194,7 @@ paths:
|
|||||||
summary: Report account-link status and in-game instructions (web side, spec §10).
|
summary: Report account-link status and in-game instructions (web side, spec §10).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4077,6 +4217,7 @@ paths:
|
|||||||
summary: Consume an in-game link code and bind the account.
|
summary: Consume an in-game link code and bind the account.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4128,6 +4269,7 @@ paths:
|
|||||||
session must have reauthed within 5 minutes (403 reauth_required).
|
session must have reauthed within 5 minutes (403 reauth_required).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4191,6 +4333,7 @@ paths:
|
|||||||
consumed, or mismatched code is a 400.
|
consumed, or mismatched code is a 400.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4219,6 +4362,11 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$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':
|
'429':
|
||||||
description: >-
|
description: >-
|
||||||
Too many incorrect attempts on this code (otp_locked), or the account's
|
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).
|
must have reauthed within 5 minutes (403 reauth_required).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4286,6 +4435,7 @@ paths:
|
|||||||
instance.
|
instance.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4320,6 +4470,7 @@ paths:
|
|||||||
configured.
|
configured.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4368,6 +4519,7 @@ paths:
|
|||||||
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
|
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4400,6 +4552,7 @@ paths:
|
|||||||
(403 reauth_required).
|
(403 reauth_required).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -4439,6 +4592,7 @@ paths:
|
|||||||
op-login or a passkey).
|
op-login or a passkey).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4467,6 +4621,7 @@ paths:
|
|||||||
bound to a fresh reauth-purpose challenge.
|
bound to a fresh reauth-purpose challenge.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -4494,6 +4649,7 @@ paths:
|
|||||||
clone check (a cloned authenticator is 400 passkey_login_invalid).
|
clone check (a cloned authenticator is 400 passkey_login_invalid).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4529,6 +4685,7 @@ paths:
|
|||||||
signing in again (403 staff_reauth).
|
signing in again (403 staff_reauth).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
@@ -4579,6 +4736,7 @@ paths:
|
|||||||
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
|
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
|
x-felis-setup-allowed: true
|
||||||
security: [{ sessionCookie: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -5412,6 +5570,11 @@ paths:
|
|||||||
content:
|
content:
|
||||||
text/event-stream:
|
text/event-stream:
|
||||||
schema: { type: string }
|
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':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
@@ -5595,6 +5758,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Submission' }
|
schema: { $ref: '#/components/schemas/Submission' }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
|
|||||||
@@ -1893,6 +1893,7 @@ func do(h http.Handler, method, target, body string, headers map[string]string)
|
|||||||
}
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
|
recordContract(r, body, w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ func TestLinkVerifyRejections(t *testing.T) {
|
|||||||
t.Run("uuid linked to another user -> 409 already_linked", func(t *testing.T) {
|
t.Run("uuid linked to another user -> 409 already_linked", func(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
repo.links[mcUUID] = "someone-else"
|
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)
|
w := do(mk(repo), "POST", "/api/v1/account/link/verify", `{"code":"FRESHCOD"}`, nil)
|
||||||
if w.Code != http.StatusConflict || decodeErr(t, w) != "already_linked" {
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "already_linked" {
|
||||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||||
@@ -238,7 +238,7 @@ func TestLinkVerifyIdempotent(t *testing.T) {
|
|||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
repo.links[mcUUID] = "u1" // already linked to THIS user
|
repo.links[mcUUID] = "u1" // already linked to THIS user
|
||||||
repo.linked["u1"] = true
|
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 := newTestAPI(repo, newFakeCluster())
|
||||||
api.External = staticExternal{p: user}
|
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)
|
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 := newTestAPI(repo, newFakeCluster())
|
||||||
api.External = staticExternal{p: user}
|
api.External = staticExternal{p: user}
|
||||||
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"TAKEOVER"}`, nil); w.Code != http.StatusOK {
|
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"TAKEOVER"}`, nil); w.Code != http.StatusOK {
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeFileEditError(w, r, err)
|
writeFileEditError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []fileedit.Entry{} // an empty directory is [], never null
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"path": path, "entries": entries, "truncated": truncated,
|
"path": path, "entries": entries, "truncated": truncated,
|
||||||
})
|
})
|
||||||
@@ -114,6 +117,9 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeFileEditError(w, r, err)
|
writeFileEditError(w, r, err)
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if servers == nil {
|
||||||
|
servers = []ServerInfo{} // an empty fleet is [], never null
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -11,5 +12,24 @@ import (
|
|||||||
// changes the day someone buys the name and which CI may not reach at all.
|
// changes the day someone buys the name and which CI may not reach at all.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
mojangProfileAPI = "http://127.0.0.1:1/"
|
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)
|
||||||
}
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ type oasOp struct {
|
|||||||
Faces []string `json:"x-felis-face"`
|
Faces []string `json:"x-felis-face"`
|
||||||
Tier string `json:"x-felis-tier"`
|
Tier string `json:"x-felis-tier"`
|
||||||
Callers []string `json:"x-felis-callers"`
|
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
|
// oasFacet is the classification of one {method, path}: which face(s) serve it
|
||||||
@@ -52,6 +54,8 @@ type oasFacet struct {
|
|||||||
tier string
|
tier string
|
||||||
// callers is the internal route's caller set, empty elsewhere.
|
// callers is the internal route's caller set, empty elsewhere.
|
||||||
callers map[string]bool
|
callers map[string]bool
|
||||||
|
// setup is the route's SetupAllowed: reachable during the setup lockdown.
|
||||||
|
setup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
||||||
@@ -83,6 +87,9 @@ func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
|||||||
if s.tier != d.tier {
|
if s.tier != d.tier {
|
||||||
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, 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) {
|
if !oasSameSet(s.callers, d.callers) {
|
||||||
t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v",
|
t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v",
|
||||||
key, oasSortedKeys(s.callers), oasSortedKeys(d.callers))
|
key, oasSortedKeys(s.callers), oasSortedKeys(d.callers))
|
||||||
@@ -99,7 +106,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
a := &API{}
|
a := &API{}
|
||||||
out := map[string]oasFacet{}
|
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
|
key := method + " " + pattern
|
||||||
f, ok := out[key]
|
f, ok := out[key]
|
||||||
if !ok {
|
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)
|
t.Fatalf("%s: route table assigns conflicting tiers %q and %q", key, f.tier, tier)
|
||||||
}
|
}
|
||||||
f.tier = tier
|
f.tier = tier
|
||||||
|
f.setup = f.setup || setup
|
||||||
out[key] = f
|
out[key] = f
|
||||||
}
|
}
|
||||||
for _, rt := range a.internalAPIRoutes() {
|
for _, rt := range a.internalAPIRoutes() {
|
||||||
@@ -120,7 +128,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
if rt.Public {
|
if rt.Public {
|
||||||
tier = "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() {
|
for _, rt := range a.externalAPIRoutes() {
|
||||||
var tier string
|
var tier string
|
||||||
@@ -134,7 +142,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
default:
|
default:
|
||||||
tier = "app"
|
tier = "app"
|
||||||
}
|
}
|
||||||
add(rt.Method, rt.Pattern, "external", tier)
|
add(rt.Method, rt.Pattern, "external", tier, rt.SetupAllowed)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -186,7 +194,7 @@ func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
for _, c := range op.Callers {
|
for _, c := range op.Callers {
|
||||||
callers[c] = true
|
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
|
return out
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ func TestMySubmissionsCarriesBuildOutcome(t *testing.T) {
|
|||||||
// the whole list; any other lookup failure must surface, never be swallowed.
|
// the whole list; any other lookup failure must surface, never be swallowed.
|
||||||
func TestMySubmissionsBuildLookupSemantics(t *testing.T) {
|
func TestMySubmissionsBuildLookupSemantics(t *testing.T) {
|
||||||
// Missing row (ErrNotFound): 200 with no build fields.
|
// 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 := appSubAPI(fs)
|
||||||
api.Builder = &fakeBuilder{}
|
api.Builder = &fakeBuilder{}
|
||||||
w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil)
|
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.
|
// with no linked build asks nothing.
|
||||||
func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) {
|
func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) {
|
||||||
fs := &fakeSubmissions{listed: []submit.Submission{
|
fs := &fakeSubmissions{listed: []submit.Submission{
|
||||||
{ID: "sub-1", BuildID: "bld-1"},
|
{ID: "sub-1", Status: submit.StatusApproved, BuildID: "bld-1"},
|
||||||
{ID: "sub-2"},
|
{ID: "sub-2", Status: submit.StatusPendingReview},
|
||||||
{ID: "sub-3", BuildID: "bld-3"},
|
{ID: "sub-3", Status: submit.StatusApproved, BuildID: "bld-3"},
|
||||||
}}
|
}}
|
||||||
fb := &fakeBuilder{getBuilds: map[string]*build.Build{
|
fb := &fakeBuilder{getBuilds: map[string]*build.Build{
|
||||||
"bld-1": {ID: "bld-1", Status: build.StatusSucceeded},
|
"bld-1": {ID: "bld-1", Status: build.StatusSucceeded},
|
||||||
@@ -518,7 +518,7 @@ func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) {
|
|||||||
t.Fatalf("outcomes = %+v", got.Submissions)
|
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
|
fb.getManyIDs = nil
|
||||||
if w := do(api.ExternalHandler(), "GET", "/api/v1/submissions", "", nil); w.Code != http.StatusOK {
|
if w := do(api.ExternalHandler(), "GET", "/api/v1/submissions", "", nil); w.Code != http.StatusOK {
|
||||||
t.Fatalf("unlinked page: code = %d", w.Code)
|
t.Fatalf("unlinked page: code = %d", w.Code)
|
||||||
|
|||||||
@@ -2396,6 +2396,69 @@ export interface components {
|
|||||||
};
|
};
|
||||||
content?: never;
|
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). */
|
/** @description Malformed or invalid request (validation, bad body, unknown field). */
|
||||||
BadRequest: {
|
BadRequest: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2850,6 +2913,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"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: {
|
internalStatus: {
|
||||||
@@ -3101,6 +3173,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
400: components["responses"]["BadRequest"];
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
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: {
|
checkUsernameBlacklist: {
|
||||||
@@ -3403,6 +3484,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"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: {
|
stop: {
|
||||||
@@ -3559,6 +3649,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
|
/** @description Unknown server. */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description Server not running. */
|
/** @description Server not running. */
|
||||||
409: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -3568,6 +3667,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"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"];
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4119,7 +4227,7 @@ export interface operations {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
role: "user" | "admin";
|
role: "owner" | "admin" | "user";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4266,7 +4374,7 @@ export interface operations {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
role: "user" | "admin";
|
role: "owner" | "admin" | "user";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4405,7 +4513,7 @@ export interface operations {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
role: "user" | "admin";
|
role: "owner" | "admin" | "user";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4566,7 +4674,7 @@ export interface operations {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
role: "user" | "admin";
|
role: "owner" | "admin" | "user";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4625,7 +4733,7 @@ export interface operations {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
role: "user" | "admin";
|
role: "owner" | "admin" | "user";
|
||||||
email: string;
|
email: string;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
has_passkey: boolean;
|
has_passkey: boolean;
|
||||||
@@ -4682,7 +4790,7 @@ export interface operations {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
role: "user" | "admin";
|
role: "owner" | "admin" | "user";
|
||||||
email: string;
|
email: string;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
has_passkey: boolean;
|
has_passkey: boolean;
|
||||||
@@ -4814,6 +4922,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
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: {
|
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"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
/** @description No matching backup. */
|
/** @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"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
/** @description Unknown server. */
|
/** @description Unknown server. */
|
||||||
@@ -5070,7 +5205,17 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"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"];
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
507: components["responses"]["InsufficientStorage"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
listServerJobs: {
|
listServerJobs: {
|
||||||
@@ -5289,6 +5434,7 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"application/json": components["schemas"]["Error"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
507: components["responses"]["InsufficientStorage"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
writeServerFile: {
|
writeServerFile: {
|
||||||
@@ -5643,6 +5789,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
|
/** @description Unknown user. */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
setQuotas: {
|
setQuotas: {
|
||||||
@@ -5677,6 +5832,15 @@ export interface operations {
|
|||||||
400: components["responses"]["BadRequest"];
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
|
/** @description Unknown user. */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
listUserSessions: {
|
listUserSessions: {
|
||||||
@@ -5837,6 +6001,15 @@ export interface operations {
|
|||||||
400: components["responses"]["BadRequest"];
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
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. */
|
/** @description UUID is already linked to a different user. */
|
||||||
409: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -6058,6 +6231,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
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). */
|
/** @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: {
|
429: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -7230,6 +7412,15 @@ export interface operations {
|
|||||||
"text/event-stream": string;
|
"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"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
@@ -7405,6 +7596,7 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["Submission"];
|
"application/json": components["schemas"]["Submission"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
|
|||||||
Reference in new issue
Block a user