fix(api): close second OpenAPI contract review gaps

Compare candidates against the protected merge-base/PR-base OpenAPI via
oasdiff, harden Fiber shadow detection and middleware test isolation, and
align /api/v1 input and status claims with handlers.
This commit is contained in:
0sm0s1z
2026-07-31 09:29:10 -07:00
parent 48643b912d
commit d7da4709fc
20 changed files with 1251 additions and 604 deletions
+34 -1
View File
@@ -257,6 +257,8 @@ jobs:
steps:
- name: Checkout Sirius repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate image tag
id: meta
@@ -286,6 +288,35 @@ jobs:
fi
echo "image_tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Resolve protected OpenAPI baseline ref
id: openapi_base
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
GIT_REF: ${{ github.ref }}
run: |
set -euo pipefail
BASE_REF=""
if [ "${EVENT_NAME}" = "pull_request" ] && [ -n "${PR_BASE_SHA}" ]; then
BASE_REF="${PR_BASE_SHA}"
elif [ "${EVENT_NAME}" = "push" ] && [ "${GIT_REF}" = "refs/heads/main" ] && [ -n "${PUSH_BEFORE}" ] && [ "${PUSH_BEFORE}" != "0000000000000000000000000000000000000000" ]; then
BASE_REF="${PUSH_BEFORE}"
else
git fetch origin main --depth=1 2>/dev/null || true
if git rev-parse --verify origin/main >/dev/null 2>&1; then
BASE_REF="$(git merge-base HEAD origin/main)"
elif git rev-parse --verify main >/dev/null 2>&1; then
BASE_REF="$(git merge-base HEAD main)"
fi
fi
if [ -z "${BASE_REF}" ]; then
echo "Unable to resolve protected OpenAPI base ref" >&2
exit 1
fi
echo "base_ref=${BASE_REF}" >> "$GITHUB_OUTPUT"
echo "Protected OpenAPI base ref: ${BASE_REF}"
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -298,8 +329,10 @@ jobs:
working-directory: sirius-api
- name: Verify API module composition contract
env:
SIRIUS_OPENAPI_BASE_REF: ${{ steps.openapi_base.outputs.base_ref }}
run: |
go test . -run 'TestCommunityModuleInventory|TestCommunityRouteInventory|TestExtensionModuleAddsRouteWithoutChangingCommunityComposition|TestLiveRouteContractCoverage|TestUnclassifiedLiveRouteIsRejected|TestProductionMiddlewareAuthAndRequestID|TestFixedRoutesNotShadowedByParams' -count=1
go test . -run 'TestCommunityModuleInventory|TestCommunityRouteInventory|TestExtensionModuleAddsRouteWithoutChangingCommunityComposition|TestLiveRouteContractCoverage|TestUnclassifiedLiveRouteIsRejected|TestProductionMiddlewareAuthAndRequestID|TestProductionMiddlewareLoggingUsesInProcessSink|TestFixedRoutesNotShadowedByParams' -count=1
go test ./internal/contract/ ./middleware/ -count=1
working-directory: sirius-api
@@ -69,10 +69,11 @@ go test . -run 'TestCommunityRouteInventory|TestLiveRouteContractCoverage' -coun
| Artifact | Path |
| --- | --- |
| OpenAPI 3.0.3 contract | `sirius-api/contracts/openapi.v1.yaml` |
| Semantic breaking baseline | `sirius-api/contracts/openapi.v1.baseline.yaml` |
| Local seed copy (non-authoritative) | `sirius-api/contracts/openapi.v1.baseline.yaml` |
| Route classification inventory | `sirius-api/contracts/route_classification.yaml` |
| Ordered live-route golden | `sirius-api/testdata/community_routes.golden` |
| Operation-removal fixture | `sirius-api/contracts/fixtures/breaking_openapi.missing_operation.yaml` |
| oasdiff mini fixtures | `sirius-api/contracts/fixtures/breaking_base_mini.yaml` |
| Validator package | `sirius-api/internal/contract/` |
### Updating the Contract
@@ -85,14 +86,28 @@ go test . -run 'TestCommunityRouteInventory|TestLiveRouteContractCoverage' -coun
`deprecated`). Keep fixed routes registered before parameterized siblings.
4. Update `openapi.v1.yaml` for every live `/api/v1` operation. Do not invent
behavior the handlers do not provide.
5. Run `go test ./internal/contract/ -count=1`. Semantic breaking detection
compares the published contract to `openapi.v1.baseline.yaml` and rejects
removed operations, removed/changed security, removed response codes,
schema/type changes, and newly required parameters/request fields.
6. When the published contract change is intentionally accepted, copy
`openapi.v1.yaml` over `openapi.v1.baseline.yaml` in the same change set:
5. Run `go test ./internal/contract/ -count=1`. CI compares the candidate
`openapi.v1.yaml` against the **protected** copy at the PR base / merge-base /
previous main tip (`SIRIUS_OPENAPI_BASE_REF`), using pinned **oasdiff** ERR
checks. Editing `openapi.v1.baseline.yaml` in the same feature PR cannot bypass
that gate.
6. **Bootstrap (this introductory PR only):** if the protected base does not yet
contain `openapi.v1.yaml`, comparison is skipped (bootstrap). After merge to
main the check is fail-closed.
7. **Intentional breaking advancement:** open a dedicated contract-change PR (or
obtain an explicit human gate) and set `SIRIUS_OPENAPI_ALLOW_BREAKING=1` in the
approved workflow only. Do **not** silently advance a local baseline alongside
a breaking feature change.
8. Optionally refresh the local seed copy for docs/fixtures after an accepted
non-breaking edit:
`cp sirius-api/contracts/openapi.v1.yaml sirius-api/contracts/openapi.v1.baseline.yaml`
7. Keep the operation-removal fixture failing coverage validation.
9. Keep the operation-removal fixture failing coverage validation.
### Route shadowing policy
Shadow detection mounts route pairs into a real Fiber app. The deprecated
duplicate `GET /host/source-coverage` (second registration) is allowlisted for
inventory fidelity; public/internal shadowed routes fail CI.
## What It Is
@@ -157,8 +172,9 @@ The API build job in `.github/workflows/ci.yml` runs:
1. Existing module/route golden tests from task 3.1
2. Live Fiber ↔ classification ↔ OpenAPI coverage tests
3. `internal/contract` suite, including the breaking OpenAPI fixture that removes
`GET /api/v1/scans/status` and must fail validation
3. Production middleware tests with an in-process no-network logging sink
4. `internal/contract` suite: coverage, Fiber shadow detector, oasdiff negative
fixtures, and protected-baseline comparison via `SIRIUS_OPENAPI_BASE_REF`
### TypeScript Client
+4 -1
View File
@@ -552,7 +552,10 @@ Cycle 11 local evidence:
- Review corrections: fixed event/agent-template route shadowing; semantic OpenAPI
baseline breaking detection; production middleware shared by main/tests; exact
GET `/health` auth skip; request/response OpenAPI accuracy; Fiber `utils.UUID`
request-ID semantics. Task 3.2 remains `in_progress` until live CI.
request-ID semantics.
- Second review: protected-base oasdiff gate (not candidate-controlled baseline),
Fiber shadow detector, no-network middleware sink, OpenAPI input/status audit.
Task 3.2 remains `in_progress` until live CI.
## Stage 5: Entitlements and First Vertical
@@ -205,6 +205,10 @@ Local task evidence (cycle 1):
- Review corrections: route shadowing fixes, semantic baseline breaking detection,
production middleware test stack, exact `/health` auth bypass, OpenAPI
request/response accuracy, Fiber `utils.UUID` request-ID docs/tests.
- Second independent review: protected merge-base/PR-base OpenAPI comparison
(bootstrap → fail-closed; `SIRIUS_OPENAPI_ALLOW_BREAKING` gate); pinned oasdiff
with negative fixtures; Fiber-mounted shadow detector; in-process no-network
logging sink for production middleware tests; OpenAPI input/status accuracy.
- TypeScript client intentionally deferred; no new generated bulk.
- Bifurcation task 3.2 remains `in_progress` pending live CI.
@@ -0,0 +1,103 @@
openapi: 3.0.3
info:
title: Mini breaking fixture
version: "1.0.0"
paths:
/api/v1/demo:
get:
operationId: getDemo
# OR: either scheme satisfies (array of requirement objects).
security:
- ApiKeyAuth: []
- BearerAuth: []
parameters:
- name: filter
in: query
required: false
schema:
type: string
enum: [a, b, c]
- name: tag
in: query
required: false
schema:
type: string
responses:
'200':
description: ok
content:
application/json:
schema:
type: object
required: [status]
properties:
status:
type: string
values:
type: array
items:
type: string
'401':
description: unauthorized
content:
application/json:
schema:
type: object
properties:
error:
type: string
post:
operationId: postDemo
security:
- ApiKeyAuth: []
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
name:
type: string
mode:
type: string
enum: [read, write]
additionalProperties: false
responses:
'200':
description: ok
content:
application/json:
schema:
type: object
required: [id]
properties:
id:
type: string
items:
type: array
items:
type: object
properties:
nested:
type: string
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
BearerAuth:
type: http
scheme: bearer
schemas:
DemoResponse:
type: object
required: [status]
properties:
status:
type: string
values:
type: array
items:
type: string
+113 -11
View File
@@ -146,6 +146,13 @@ paths:
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'500':
description: Docker/service failure
content:
application/json:
schema:
type: object
additionalProperties: true
/api/v1/system/resources:
get:
@@ -181,6 +188,44 @@ paths:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
- name: service
in: query
required: false
schema:
type: string
- name: endpoint
in: query
required: false
schema:
type: string
- name: method
in: query
required: false
schema:
type: string
- name: time_range
in: query
required: false
description: Handler default "1h".
schema:
type: string
default: "1h"
- name: limit
in: query
required: false
description: Handler default 100; values below 1 fall back to 100; capped at 1000.
schema:
type: integer
default: 100
maximum: 1000
- name: offset
in: query
required: false
description: Handler default 0; negative values fall back to 0.
schema:
type: integer
default: 0
minimum: 0
responses:
'200':
description: Placeholder metrics payload
@@ -194,6 +239,8 @@ paths:
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/ErrorWithDetails'
/api/v1/logs/stats:
get:
@@ -231,6 +278,42 @@ paths:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
- name: service
in: query
required: false
schema:
type: string
- name: level
in: query
required: false
schema:
type: string
- name: subcomponent
in: query
required: false
schema:
type: string
- name: search
in: query
required: false
schema:
type: string
- name: limit
in: query
required: false
description: Handler default 100; values below 1 fall back to 100; capped at 1000.
schema:
type: integer
default: 100
maximum: 1000
- name: offset
in: query
required: false
description: Handler default 0; negative values fall back to 0.
schema:
type: integer
default: 0
minimum: 0
responses:
'200':
description: Log list payload
@@ -354,12 +437,12 @@ paths:
schema:
type: object
additionalProperties: true
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
$ref: '#/components/responses/ErrorWithDetails'
/api/v1/logs/clear:
delete:
@@ -568,10 +651,19 @@ paths:
- $ref: '#/components/parameters/RequestID'
- name: entity_type
in: query
required: true
schema: { type: string }
- name: entity_id
in: query
required: true
schema: { type: string }
- name: limit
in: query
required: false
description: Handler default 50; values below 1 fall back to 50.
schema:
type: integer
default: 50
responses:
'200':
description: Events for entity
@@ -604,6 +696,13 @@ paths:
required: true
schema:
type: string
- name: limit
in: query
required: false
description: Handler default 50; values below 1 fall back to 50.
schema:
type: integer
default: 50
responses:
'200':
description: Events matching severity
@@ -631,6 +730,14 @@ paths:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
- name: limit
in: query
required: false
description: Number of snapshots. Handler default 7; values below 1 fall back to 7; capped at 10.
schema:
type: integer
default: 7
maximum: 10
responses:
'200':
description: Trend payload
@@ -709,16 +816,13 @@ paths:
tags: [statistics]
operationId: createVulnerabilitySnapshot
summary: Create a vulnerability snapshot
description: |
Manual trigger. Handler parses no request body and auto-generates a
timestamp-based snapshot ID.
security:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
requestBody:
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'201':
description: Snapshot created
@@ -730,8 +834,6 @@ paths:
schema:
type: object
additionalProperties: true
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
+113 -11
View File
@@ -146,6 +146,13 @@ paths:
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'500':
description: Docker/service failure
content:
application/json:
schema:
type: object
additionalProperties: true
/api/v1/system/resources:
get:
@@ -181,6 +188,44 @@ paths:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
- name: service
in: query
required: false
schema:
type: string
- name: endpoint
in: query
required: false
schema:
type: string
- name: method
in: query
required: false
schema:
type: string
- name: time_range
in: query
required: false
description: Handler default "1h".
schema:
type: string
default: "1h"
- name: limit
in: query
required: false
description: Handler default 100; values below 1 fall back to 100; capped at 1000.
schema:
type: integer
default: 100
maximum: 1000
- name: offset
in: query
required: false
description: Handler default 0; negative values fall back to 0.
schema:
type: integer
default: 0
minimum: 0
responses:
'200':
description: Placeholder metrics payload
@@ -194,6 +239,8 @@ paths:
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/ErrorWithDetails'
/api/v1/logs/stats:
get:
@@ -231,6 +278,42 @@ paths:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
- name: service
in: query
required: false
schema:
type: string
- name: level
in: query
required: false
schema:
type: string
- name: subcomponent
in: query
required: false
schema:
type: string
- name: search
in: query
required: false
schema:
type: string
- name: limit
in: query
required: false
description: Handler default 100; values below 1 fall back to 100; capped at 1000.
schema:
type: integer
default: 100
maximum: 1000
- name: offset
in: query
required: false
description: Handler default 0; negative values fall back to 0.
schema:
type: integer
default: 0
minimum: 0
responses:
'200':
description: Log list payload
@@ -354,12 +437,12 @@ paths:
schema:
type: object
additionalProperties: true
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
$ref: '#/components/responses/ErrorWithDetails'
/api/v1/logs/clear:
delete:
@@ -568,10 +651,19 @@ paths:
- $ref: '#/components/parameters/RequestID'
- name: entity_type
in: query
required: true
schema: { type: string }
- name: entity_id
in: query
required: true
schema: { type: string }
- name: limit
in: query
required: false
description: Handler default 50; values below 1 fall back to 50.
schema:
type: integer
default: 50
responses:
'200':
description: Events for entity
@@ -604,6 +696,13 @@ paths:
required: true
schema:
type: string
- name: limit
in: query
required: false
description: Handler default 50; values below 1 fall back to 50.
schema:
type: integer
default: 50
responses:
'200':
description: Events matching severity
@@ -631,6 +730,14 @@ paths:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
- name: limit
in: query
required: false
description: Number of snapshots. Handler default 7; values below 1 fall back to 7; capped at 10.
schema:
type: integer
default: 7
maximum: 10
responses:
'200':
description: Trend payload
@@ -709,16 +816,13 @@ paths:
tags: [statistics]
operationId: createVulnerabilitySnapshot
summary: Create a vulnerability snapshot
description: |
Manual trigger. Handler parses no request body and auto-generates a
timestamp-based snapshot ID.
security:
- ApiKeyAuth: []
parameters:
- $ref: '#/components/parameters/RequestID'
requestBody:
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'201':
description: Snapshot created
@@ -730,8 +834,6 @@ paths:
schema:
type: object
additionalProperties: true
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
+11 -1
View File
@@ -7,10 +7,13 @@ require (
github.com/getkin/kin-openapi v0.132.0
github.com/gofiber/fiber/v2 v2.49.2
github.com/google/uuid v1.6.0
github.com/oasdiff/oasdiff v1.11.7
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.121.6 // indirect
github.com/TwiN/go-color v1.4.1 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
@@ -22,7 +25,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
@@ -34,11 +37,18 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/streadway/amqp v1.1.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/valkey-io/valkey-go v1.0.60 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.50.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
+11 -1
View File
@@ -7,10 +7,13 @@ require (
github.com/getkin/kin-openapi v0.132.0
github.com/gofiber/fiber/v2 v2.49.2
github.com/google/uuid v1.6.0
github.com/oasdiff/oasdiff v1.11.7
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.121.6 // indirect
github.com/TwiN/go-color v1.4.1 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
@@ -22,7 +25,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
@@ -34,11 +37,18 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/streadway/amqp v1.1.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/valkey-io/valkey-go v1.0.60 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.50.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
+30 -7
View File
@@ -1,10 +1,14 @@
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
github.com/SiriusScan/go-api v0.0.20-0.20260731061449-ebd42f4239ec h1:MTzLea0wZqkECmaSfENgPWUKQMXDqrS0M0HHVrhAro0=
github.com/SiriusScan/go-api v0.0.20-0.20260731061449-ebd42f4239ec/go.mod h1:OnL5Cp7xjpa0NdaZ5jbG9NKfln2V6gihRaqKJjOKAxw=
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -39,8 +43,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -50,6 +54,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/oasdiff v1.11.7 h1:T6DCQpb0kl9ZLfKHbJQpcDMFI+HuZ2ZDQmw8+ZeUnoc=
github.com/oasdiff/oasdiff v1.11.7/go.mod h1:pwjWpNj1T83BclvmoU7lyx+sEBlW6U7l5KVp7DCZEDM=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -58,8 +64,9 @@ github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -72,8 +79,18 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valkey-io/valkey-go v1.0.60 h1:idh959D20H5n7D/kwEdTKNaMn5+4HpZTn7bLXnAhQIw=
github.com/valkey-io/valkey-go v1.0.60/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -82,8 +99,14 @@ github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e
github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+30 -7
View File
@@ -1,10 +1,14 @@
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
github.com/SiriusScan/go-api v0.0.20-0.20260731061449-ebd42f4239ec h1:MTzLea0wZqkECmaSfENgPWUKQMXDqrS0M0HHVrhAro0=
github.com/SiriusScan/go-api v0.0.20-0.20260731061449-ebd42f4239ec/go.mod h1:OnL5Cp7xjpa0NdaZ5jbG9NKfln2V6gihRaqKJjOKAxw=
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -39,8 +43,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -50,6 +54,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/oasdiff v1.11.7 h1:T6DCQpb0kl9ZLfKHbJQpcDMFI+HuZ2ZDQmw8+ZeUnoc=
github.com/oasdiff/oasdiff v1.11.7/go.mod h1:pwjWpNj1T83BclvmoU7lyx+sEBlW6U7l5KVp7DCZEDM=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -58,8 +64,9 @@ github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -72,8 +79,18 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valkey-io/valkey-go v1.0.60 h1:idh959D20H5n7D/kwEdTKNaMn5+4HpZTn7bLXnAhQIw=
github.com/valkey-io/valkey-go v1.0.60/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -82,8 +99,14 @@ github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e
github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+74 -4
View File
@@ -5,18 +5,53 @@ package main
import (
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"regexp"
"sync/atomic"
"testing"
"time"
"github.com/SiriusScan/go-api/sirius/logging"
"github.com/SiriusScan/go-api/sirius/module"
"github.com/gofiber/fiber/v2"
)
var fiberUUIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
// configureNoNetworkLoggingSink points the SDK logging client at an in-process
// httptest.Server with Async=false and Postgres events disabled so production
// middleware tests never dial localhost:9001 or open DB connections.
func configureNoNetworkLoggingSink(t *testing.T) *int32 {
t.Helper()
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"message":"ok"}`))
}))
t.Cleanup(srv.Close)
logging.InitWithConfig(&logging.LogConfig{
APIBaseURL: srv.URL + "/api/v1/logs",
Timeout: 500 * time.Millisecond,
MaxRetries: 0,
RetryDelay: 0,
Async: false,
BufferSize: 1,
FlushInterval: time.Second,
EnablePostgresEvents: false,
})
t.Cleanup(func() {
logging.Close()
})
return &hits
}
func newProductionTestApp(t *testing.T, rootKey string) *fiber.App {
t.Helper()
_ = configureNoNetworkLoggingSink(t)
registry, err := buildModuleRegistry(nil)
if err != nil {
t.Fatalf("build registry: %v", err)
@@ -100,13 +135,50 @@ func TestProductionMiddlewareAuthAndRequestID(t *testing.T) {
})
}
func TestProductionMiddlewareLoggingUsesInProcessSink(t *testing.T) {
hits := configureNoNetworkLoggingSink(t)
const rootKey = "test-root-key"
registry, err := buildModuleRegistry(nil)
if err != nil {
t.Fatalf("build registry: %v", err)
}
app := fiber.New()
applyProductionHTTPMiddleware(app, nil, rootKey)
if err := registry.Mount(app, module.NoopJobRegistrar{}, module.NoopEventRegistrar{}); err != nil {
t.Fatalf("mount: %v", err)
}
// API-key middleware short-circuits before SDK logging on 401, so exercise an
// authenticated domain 400 that passes through the full production stack.
req := httptest.NewRequest(fiber.MethodGet, "/api/v1/events/by-entity", nil)
req.Header.Set("X-API-Key", rootKey)
resp, err := app.Test(req, 2000)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
if atomic.LoadInt32(hits) < 1 {
t.Fatal("expected in-process logging sink to receive at least one submission")
}
// Prove default external targets are not required for this stack.
conn, dialErr := net.DialTimeout("tcp", "127.0.0.1:9001", 50*time.Millisecond)
if dialErr == nil {
_ = conn.Close()
t.Log("localhost:9001 happens to be listening; test still used in-process sink")
}
}
func TestFixedRoutesNotShadowedByParams(t *testing.T) {
const rootKey = "test-root-key"
app := newProductionTestApp(t, rootKey)
cases := []struct {
path string
mustNotBeID bool
path string
}{
{path: "/api/v1/events/stats"},
{path: "/api/v1/events/by-entity"},
@@ -122,8 +194,6 @@ func TestFixedRoutesNotShadowedByParams(t *testing.T) {
t.Fatal(err)
}
defer resp.Body.Close()
// Shadowed routes typically 404/500 as an event/template id lookup.
// Reachable fixed handlers return 200 or a non-404 domain error.
if resp.StatusCode == fiber.StatusNotFound {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("fixed route appears shadowed (404): %s", body)
+13 -366
View File
@@ -1,369 +1,16 @@
package contract
import (
"fmt"
"sort"
"strings"
"github.com/getkin/kin-openapi/openapi3"
)
// DefaultBaselineOpenAPIPath is the checked-in accepted contract used for
// semantic breaking-change detection. Update procedure:
// 1. Intentionally revise contracts/openapi.v1.yaml
// 2. Ensure additive/non-breaking diffs pass ValidateNoBreakingChanges
// 3. For accepted intentional contract evolution (including additive publish),
// copy openapi.v1.yaml over openapi.v1.baseline.yaml in the same change
// 4. Re-run go test ./internal/contract/
// DefaultBaselineOpenAPIPath is a local seed/documentation copy of the last
// accepted published contract. CI and fail-closed tests MUST NOT treat this
// file as authoritative — use ResolveProtectedBaseline / CheckProtectedBreaking
// against the protected git ref (merge-base / PR base / main).
//
// Baseline advancement procedure:
// 1. Open a dedicated contract-change PR (or obtain an explicit human gate).
// 2. Update sirius-api/contracts/openapi.v1.yaml with the intentional change.
// 3. CI compares the candidate against openapi.v1.yaml at the protected base.
// 4. Breaking changes fail unless SIRIUS_OPENAPI_ALLOW_BREAKING=1 is set by an
// approved advancement workflow (not by editing any local baseline file).
// 5. After merge to main, the protected baseline advances immutably with main.
// 6. Optionally refresh openapi.v1.baseline.yaml to match for local docs/fixtures.
const DefaultBaselineOpenAPIPath = "contracts/openapi.v1.baseline.yaml"
// BreakingChange is one deterministic semantic regression between baseline and candidate.
type BreakingChange struct {
Operation string
Kind string
Detail string
}
func (b BreakingChange) String() string {
return fmt.Sprintf("%s: %s (%s)", b.Kind, b.Detail, b.Operation)
}
// ValidateNoBreakingChanges compares candidate against baseline and rejects
// meaningful contract regressions for /api/v1 operations.
func ValidateNoBreakingChanges(baseline, candidate *OpenAPIContract) error {
changes := DiffBreakingChanges(baseline, candidate)
if len(changes) == 0 {
return nil
}
msgs := make([]string, 0, len(changes))
for _, change := range changes {
msgs = append(msgs, change.String())
}
sort.Strings(msgs)
return fmt.Errorf("semantic breaking API contract changes:\n - %s", strings.Join(msgs, "\n - "))
}
// DiffBreakingChanges returns deterministic breaking diffs for /api/v1 ops.
func DiffBreakingChanges(baseline, candidate *OpenAPIContract) []BreakingChange {
var out []BreakingChange
baseOps := indexOperations(baseline.Doc)
candOps := indexOperations(candidate.Doc)
var keys []string
for key := range baseOps {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
_, path, _ := strings.Cut(key, "\t")
if !IsAPIV1(path) {
continue
}
base := baseOps[key]
cand, ok := candOps[key]
if !ok {
out = append(out, BreakingChange{
Operation: displayOp(key),
Kind: "removed_operation",
Detail: "operation removed from candidate contract",
})
continue
}
out = append(out, diffOperation(key, base, cand)...)
}
return out
}
type opView struct {
security []string
responses map[string]*openapi3.Response
params []paramView
requestReq bool
reqProps map[string]schemaView
reqRequired map[string]bool
}
type paramView struct {
locate string // in:name
required bool
schema schemaView
}
type schemaView struct {
types []string
ref string
}
func indexOperations(doc *openapi3.T) map[string]opView {
out := make(map[string]opView)
if doc == nil || doc.Paths == nil {
return out
}
for path, item := range doc.Paths.Map() {
if item == nil {
continue
}
for method, op := range item.Operations() {
if op == nil {
continue
}
out[OperationKey(method, path)] = buildOpView(op)
}
}
return out
}
func buildOpView(op *openapi3.Operation) opView {
view := opView{
responses: map[string]*openapi3.Response{},
reqProps: map[string]schemaView{},
reqRequired: map[string]bool{},
}
if op.Security != nil {
for _, req := range *op.Security {
for name := range req {
view.security = append(view.security, name)
}
}
}
sort.Strings(view.security)
if op.Responses != nil {
for code, ref := range op.Responses.Map() {
if ref != nil {
view.responses[code] = ref.Value
}
}
}
for _, pref := range op.Parameters {
if pref == nil || pref.Value == nil {
continue
}
p := pref.Value
view.params = append(view.params, paramView{
locate: p.In + ":" + p.Name,
required: p.Required,
schema: schemaFrom(p.Schema),
})
}
sort.Slice(view.params, func(i, j int) bool {
return view.params[i].locate < view.params[j].locate
})
if op.RequestBody != nil && op.RequestBody.Value != nil {
view.requestReq = op.RequestBody.Value.Required
if mt := op.RequestBody.Value.Content.Get("application/json"); mt != nil {
props, required := objectProps(mt.Schema)
view.reqProps = props
view.reqRequired = required
}
}
return view
}
func schemaFrom(ref *openapi3.SchemaRef) schemaView {
if ref == nil {
return schemaView{}
}
view := schemaView{ref: ref.Ref}
if ref.Value != nil && ref.Value.Type != nil {
view.types = append([]string{}, ref.Value.Type.Slice()...)
sort.Strings(view.types)
}
return view
}
func objectProps(ref *openapi3.SchemaRef) (map[string]schemaView, map[string]bool) {
props := map[string]schemaView{}
required := map[string]bool{}
if ref == nil || ref.Value == nil {
return props, required
}
schema := ref.Value
if schema.AllOf != nil {
for _, part := range schema.AllOf {
p, r := objectProps(part)
for k, v := range p {
props[k] = v
}
for k, v := range r {
required[k] = v
}
}
}
for name, pref := range schema.Properties {
props[name] = schemaFrom(pref)
}
for _, name := range schema.Required {
required[name] = true
}
return props, required
}
func diffOperation(key string, base, cand opView) []BreakingChange {
var out []BreakingChange
opName := displayOp(key)
if !sameStringSet(base.security, cand.security) {
// Removing auth or changing required schemes is breaking.
if len(base.security) > 0 && (len(cand.security) == 0 || !subsetStrings(base.security, cand.security)) {
out = append(out, BreakingChange{
Operation: opName,
Kind: "security_changed",
Detail: fmt.Sprintf("security %v -> %v", base.security, cand.security),
})
}
}
for code := range base.responses {
if _, ok := cand.responses[code]; !ok {
out = append(out, BreakingChange{
Operation: opName,
Kind: "removed_response",
Detail: "response status " + code + " removed",
})
continue
}
baseSchema := jsonSchema(base.responses[code])
candSchema := jsonSchema(cand.responses[code])
if baseSchema.types != nil && candSchema.types != nil && !sameStringSet(baseSchema.types, candSchema.types) {
out = append(out, BreakingChange{
Operation: opName,
Kind: "response_schema_type_changed",
Detail: fmt.Sprintf("status %s schema type %v -> %v", code, baseSchema.types, candSchema.types),
})
}
// Detect property type changes on object response schemas.
baseProps, _ := objectProps(responseSchemaRef(base.responses[code]))
candProps, _ := objectProps(responseSchemaRef(cand.responses[code]))
for name, bprop := range baseProps {
cprop, ok := candProps[name]
if !ok {
out = append(out, BreakingChange{
Operation: opName,
Kind: "response_property_removed",
Detail: fmt.Sprintf("status %s property %q removed", code, name),
})
continue
}
if len(bprop.types) > 0 && len(cprop.types) > 0 && !sameStringSet(bprop.types, cprop.types) {
out = append(out, BreakingChange{
Operation: opName,
Kind: "response_schema_type_changed",
Detail: fmt.Sprintf("status %s property %q type %v -> %v", code, name, bprop.types, cprop.types),
})
}
}
}
baseParams := map[string]paramView{}
for _, p := range base.params {
baseParams[p.locate] = p
}
candParams := map[string]paramView{}
for _, p := range cand.params {
candParams[p.locate] = p
}
for locate, cp := range candParams {
bp, ok := baseParams[locate]
if !ok {
if cp.required {
out = append(out, BreakingChange{
Operation: opName,
Kind: "new_required_parameter",
Detail: "parameter " + locate + " newly required",
})
}
continue
}
if !bp.required && cp.required {
out = append(out, BreakingChange{
Operation: opName,
Kind: "parameter_became_required",
Detail: "parameter " + locate + " became required",
})
}
if len(bp.schema.types) > 0 && len(cp.schema.types) > 0 && !sameStringSet(bp.schema.types, cp.schema.types) {
out = append(out, BreakingChange{
Operation: opName,
Kind: "parameter_type_changed",
Detail: fmt.Sprintf("parameter %s type %v -> %v", locate, bp.schema.types, cp.schema.types),
})
}
}
for name, required := range cand.reqRequired {
if !required {
continue
}
if !base.reqRequired[name] {
out = append(out, BreakingChange{
Operation: opName,
Kind: "new_required_request_field",
Detail: "request field " + name + " newly required",
})
}
}
for name, bprop := range base.reqProps {
cprop, ok := cand.reqProps[name]
if !ok {
continue
}
if len(bprop.types) > 0 && len(cprop.types) > 0 && !sameStringSet(bprop.types, cprop.types) {
out = append(out, BreakingChange{
Operation: opName,
Kind: "request_field_type_changed",
Detail: fmt.Sprintf("request field %q type %v -> %v", name, bprop.types, cprop.types),
})
}
}
return out
}
func jsonSchema(resp *openapi3.Response) schemaView {
ref := responseSchemaRef(resp)
return schemaFrom(ref)
}
func responseSchemaRef(resp *openapi3.Response) *openapi3.SchemaRef {
if resp == nil || resp.Content == nil {
return nil
}
if mt := resp.Content.Get("application/json"); mt != nil {
return mt.Schema
}
return nil
}
func displayOp(key string) string {
return strings.ReplaceAll(key, "\t", " ")
}
func sameStringSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func subsetStrings(need, have []string) bool {
set := map[string]struct{}{}
for _, h := range have {
set[h] = struct{}{}
}
for _, n := range need {
if _, ok := set[n]; !ok {
return false
}
}
return true
}
+229 -102
View File
@@ -2,6 +2,7 @@ package contract
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -9,159 +10,285 @@ import (
"gopkg.in/yaml.v3"
)
func TestPublishedMatchesBaselineWithoutBreakingChanges(t *testing.T) {
root := ModuleRoot()
baseline, err := LoadOpenAPI(filepath.Join(root, DefaultBaselineOpenAPIPath))
func TestProtectedBaselineBootstrapOnIntroPR(t *testing.T) {
root := RepoRoot()
candidate := filepath.Join(ModuleRoot(), DefaultOpenAPIPath)
res, err := CheckProtectedBreaking(root, candidate, "")
if err != nil {
t.Fatalf("load baseline: %v", err)
t.Fatalf("bootstrap/protected check failed: %v", err)
}
published, err := LoadOpenAPI(filepath.Join(root, DefaultOpenAPIPath))
t.Cleanup(res.Cleanup)
if res.Mode != "bootstrap" && res.Mode != "protected" {
t.Fatalf("unexpected mode %q: %s", res.Mode, res.Message)
}
t.Log(res.Message)
}
func TestCandidateControlledBaselineCannotBypassProtected(t *testing.T) {
repo, baseSHA, cleanup := initProtectedBaselineRepo(t)
defer cleanup()
candidateDir := t.TempDir()
baseRaw, err := os.ReadFile(filepath.Join(repo, ProtectedOpenAPIRelPath))
if err != nil {
t.Fatalf("load published: %v", err)
t.Fatal(err)
}
if err := ValidateNoBreakingChanges(baseline, published); err != nil {
t.Fatalf("published contract introduced breaking changes vs baseline: %v", err)
var doc map[string]any
if err := yaml.Unmarshal(baseRaw, &doc); err != nil {
t.Fatal(err)
}
// Classic bypass attempt: remove a response from candidate AND advance a
// local baseline file to match the candidate.
paths := doc["paths"].(map[string]any)
demo := paths["/api/v1/demo"].(map[string]any)
get := demo["get"].(map[string]any)
responses := get["responses"].(map[string]any)
delete(responses, "401")
candRaw, err := yaml.Marshal(doc)
if err != nil {
t.Fatal(err)
}
candidatePath := filepath.Join(candidateDir, "openapi.v1.yaml")
localBaseline := filepath.Join(candidateDir, "openapi.v1.baseline.yaml")
if err := os.WriteFile(candidatePath, candRaw, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(localBaseline, candRaw, 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(EnvBaseRef, baseSHA)
t.Setenv(EnvAllowBreaking, "")
// Local baseline matches candidate (would pass a candidate-controlled check).
if err := CompareOpenAPIBreaking(localBaseline, candidatePath); err != nil {
t.Fatalf("local baseline was advanced with candidate; expected local compare to pass: %v", err)
}
res, err := CheckProtectedBreaking(repo, candidatePath, baseSHA)
if res != nil {
t.Cleanup(res.Cleanup)
}
if err == nil {
t.Fatal("expected protected baseline to reject candidate despite advanced local baseline")
}
if !strings.Contains(err.Error(), "oasdiff breaking") && !strings.Contains(err.Error(), "SIRIUS_OPENAPI_ALLOW_BREAKING") {
t.Fatalf("unexpected error: %v", err)
}
t.Setenv(EnvAllowBreaking, "1")
res2, err := CheckProtectedBreaking(repo, candidatePath, baseSHA)
if res2 != nil {
t.Cleanup(res2.Cleanup)
}
if err != nil {
t.Fatalf("allow_breaking gate should permit intentional break: %v", err)
}
if res2.Mode != "allow_breaking" {
t.Fatalf("mode=%q", res2.Mode)
}
}
func TestSemanticBreakingFixtures(t *testing.T) {
root := ModuleRoot()
baselinePath := filepath.Join(root, DefaultBaselineOpenAPIPath)
baseline, err := LoadOpenAPI(baselinePath)
func TestOasdiffNegativeBreakingClasses(t *testing.T) {
basePath := filepath.Join(ModuleRoot(), "contracts/fixtures/breaking_base_mini.yaml")
baseRaw, err := os.ReadFile(basePath)
if err != nil {
t.Fatalf("load baseline: %v", err)
t.Fatal(err)
}
cases := []struct {
name string
kind string
mutate func(doc map[string]any)
}{
{
name: "removed security",
kind: "security_changed",
name: "request_body_optional_to_required",
mutate: func(doc map[string]any) {
op := scansStatusGet(doc)
op := pathOp(doc, "/api/v1/demo", "post")
rb := op["requestBody"].(map[string]any)
rb["required"] = true
},
},
{
name: "removed_parameter",
mutate: func(doc map[string]any) {
op := pathOp(doc, "/api/v1/demo", "get")
op["parameters"] = []any{
map[string]any{
"name": "filter",
"in": "query",
"required": false,
"schema": map[string]any{
"type": "string",
"enum": []any{"a", "b", "c"},
},
},
}
},
},
{
name: "request_enum_narrowing",
mutate: func(doc map[string]any) {
op := pathOp(doc, "/api/v1/demo", "get")
params := op["parameters"].([]any)
p0 := params[0].(map[string]any)
schema := p0["schema"].(map[string]any)
schema["enum"] = []any{"a", "b"}
},
},
{
name: "security_removed",
mutate: func(doc map[string]any) {
op := pathOp(doc, "/api/v1/demo", "get")
delete(op, "security")
},
},
{
name: "removed response code",
kind: "removed_response",
name: "security_or_to_and",
mutate: func(doc map[string]any) {
op := scansStatusGet(doc)
responses := op["responses"].(map[string]any)
delete(responses, "500")
},
},
{
name: "response schema type change",
kind: "response_schema_type_changed",
mutate: func(doc map[string]any) {
op := scansStatusGet(doc)
responses := op["responses"].(map[string]any)
responses["200"] = map[string]any{
"description": "broken",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"type": "string"},
},
op := pathOp(doc, "/api/v1/demo", "get")
// AND: single requirement object with both schemes.
op["security"] = []any{
map[string]any{
"ApiKeyAuth": []any{},
"BearerAuth": []any{},
},
}
},
},
{
name: "new required parameter",
kind: "new_required_parameter",
name: "response_requiredness_added",
mutate: func(doc map[string]any) {
op := scansStatusGet(doc)
params, _ := op["parameters"].([]any)
params = append(params, map[string]any{
"name": "must-have",
"in": "query",
"required": true,
"schema": map[string]any{"type": "string"},
})
op["parameters"] = params
op := pathOp(doc, "/api/v1/demo", "get")
schema := op["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
schema["required"] = []any{"status", "values"}
},
},
{
name: "new required request field",
kind: "new_required_request_field",
name: "array_items_type_changed",
mutate: func(doc map[string]any) {
paths := doc["paths"].(map[string]any)
cancel := paths["/api/v1/scans/cancel"].(map[string]any)
post := cancel["post"].(map[string]any)
post["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{
"type": "object",
"required": []any{"scan_id", "reason"},
"properties": map[string]any{
"scan_id": map[string]any{"type": "string"},
"reason": map[string]any{"type": "string"},
},
},
},
op := pathOp(doc, "/api/v1/demo", "get")
schema := op["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
props := schema["properties"].(map[string]any)
props["values"] = map[string]any{
"type": "array",
"items": map[string]any{
"type": "integer",
},
}
},
},
{
name: "nested_response_schema_changed",
mutate: func(doc map[string]any) {
op := pathOp(doc, "/api/v1/demo", "post")
responses := op["responses"].(map[string]any)
ok := responses["200"].(map[string]any)
content := ok["content"].(map[string]any)
appJSON := content["application/json"].(map[string]any)
schema := appJSON["schema"].(map[string]any)
props := schema["properties"].(map[string]any)
props["items"] = map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
}
},
},
{
name: "response_body_removed",
mutate: func(doc map[string]any) {
op := pathOp(doc, "/api/v1/demo", "get")
responses := op["responses"].(map[string]any)
ok := responses["200"].(map[string]any)
delete(ok, "content")
},
},
{
name: "response_code_removed",
mutate: func(doc map[string]any) {
op := pathOp(doc, "/api/v1/demo", "get")
responses := op["responses"].(map[string]any)
delete(responses, "401")
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, err := os.ReadFile(baselinePath)
var doc map[string]any
if err := yaml.Unmarshal(baseRaw, &doc); err != nil {
t.Fatal(err)
}
tc.mutate(doc)
out, err := yaml.Marshal(doc)
if err != nil {
t.Fatal(err)
}
var mutated map[string]any
if err := yaml.Unmarshal(raw, &mutated); err != nil {
candPath := filepath.Join(t.TempDir(), "candidate.yaml")
if err := os.WriteFile(candPath, out, 0o644); err != nil {
t.Fatal(err)
}
tc.mutate(mutated)
out, err := yaml.Marshal(mutated)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "mutated.yaml")
if err := os.WriteFile(path, out, 0o644); err != nil {
t.Fatal(err)
}
candidate, err := LoadOpenAPI(path)
if err != nil {
t.Fatalf("load mutated openapi: %v", err)
}
err = ValidateNoBreakingChanges(baseline, candidate)
err = CompareOpenAPIBreaking(basePath, candPath)
if err == nil {
t.Fatal("expected semantic breaking change")
t.Fatalf("expected oasdiff ERR for %s", tc.name)
}
if !strings.Contains(err.Error(), tc.kind) {
t.Fatalf("expected kind %q in error, got: %v", tc.kind, err)
if !strings.Contains(err.Error(), "oasdiff breaking") {
t.Fatalf("unexpected error for %s: %v", tc.name, err)
}
t.Logf("%s: %v", tc.name, err)
})
}
}
func scansStatusGet(doc map[string]any) map[string]any {
paths := doc["paths"].(map[string]any)
status := paths["/api/v1/scans/status"].(map[string]any)
return status["get"].(map[string]any)
func TestPublishedMatchesLocalSeedWhenPresent(t *testing.T) {
// Local seed file is documentation/fixture convenience only — not the CI gate.
root := ModuleRoot()
seed := filepath.Join(root, DefaultBaselineOpenAPIPath)
published := filepath.Join(root, DefaultOpenAPIPath)
if _, err := os.Stat(seed); err != nil {
t.Skip("local seed baseline absent")
}
if err := CompareOpenAPIBreaking(seed, published); err != nil {
t.Fatalf("local seed drifted from published (refresh seed after intentional non-breaking edits): %v", err)
}
}
func TestShadowDetectorCatchesParamBeforeFixed(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/api/v1/events/:id"},
{Method: "GET", Path: "/api/v1/events/stats"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) != 1 {
t.Fatalf("findings=%v", findings)
}
if !strings.Contains(findings[0], "/api/v1/events/stats shadowed by") {
t.Fatalf("unexpected finding: %s", findings[0])
}
func pathOp(doc map[string]any, path, method string) map[string]any {
return doc["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any)
}
func initProtectedBaselineRepo(t *testing.T) (repoRoot, baseSHA string, cleanup func()) {
t.Helper()
repoRoot = t.TempDir()
run := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repoRoot
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
run("init")
run("config", "user.email", "contract-test@example.com")
run("config", "user.name", "contract-test")
baseMini, err := os.ReadFile(filepath.Join(ModuleRoot(), "contracts/fixtures/breaking_base_mini.yaml"))
if err != nil {
t.Fatal(err)
}
dst := filepath.Join(repoRoot, ProtectedOpenAPIRelPath)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, baseMini, 0o644); err != nil {
t.Fatal(err)
}
run("add", ProtectedOpenAPIRelPath)
run("commit", "-m", "seed protected openapi")
baseSHA = run("rev-parse", "HEAD")
return repoRoot, baseSHA, func() {}
}
+100
View File
@@ -0,0 +1,100 @@
package contract
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/oasdiff/oasdiff/checker"
"github.com/oasdiff/oasdiff/diff"
"github.com/oasdiff/oasdiff/load"
)
// infoBreakingCheckIDs are oasdiff INFO-level findings that Sirius treats as
// contract failures. oasdiff classifies several security/response shape changes
// as INFO by default; we still fail closed on those named classes.
var infoBreakingCheckIDs = map[string]struct{}{
"api-security-removed": {},
"api-security-added": {},
"api-global-security-removed": {},
"api-global-security-added": {},
"response-property-became-required": {},
"response-required-property-added": {},
"response-non-success-status-removed": {},
"response-success-status-removed": {},
"response-media-type-removed": {},
}
// CompareOpenAPIBreaking runs oasdiff backward-compatibility checks between
// baseline and candidate OpenAPI documents.
//
// Failure policy:
// - all ERR findings
// - all WARN findings (e.g. request-parameter-removed)
// - curated INFO findings in infoBreakingCheckIDs (security / response shape)
func CompareOpenAPIBreaking(baselinePath, candidatePath string) error {
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = false
base, err := load.NewSpecInfo(loader, load.NewSource(baselinePath))
if err != nil {
return fmt.Errorf("load baseline openapi: %w", err)
}
cand, err := load.NewSpecInfo(loader, load.NewSource(candidatePath))
if err != nil {
return fmt.Errorf("load candidate openapi: %w", err)
}
diffReport, opsSources, err := diff.GetWithOperationsSourcesMap(diff.NewConfig(), base, cand)
if err != nil {
return fmt.Errorf("oasdiff diff: %w", err)
}
cfg := checker.NewConfig(checker.GetAllChecks())
// Include INFO so curated security/response-shape IDs are visible; oasdiff's
// default CheckBackwardCompatibility stops at WARN.
changes := checker.CheckBackwardCompatibilityUntilLevel(cfg, diffReport, opsSources, checker.INFO)
msgs := make([]string, 0)
loc := checker.NewDefaultLocalizer()
for _, change := range changes {
id := change.GetId()
level := change.GetLevel()
_, curated := infoBreakingCheckIDs[id]
if level >= checker.WARN || curated {
msgs = append(msgs, fmt.Sprintf("%s/%s: %s", level, id, change.GetUncolorizedText(loc)))
}
}
if len(msgs) == 0 {
return nil
}
sort.Strings(msgs)
return fmt.Errorf("oasdiff breaking API contract changes:\n - %s", strings.Join(msgs, "\n - "))
}
// loadOpenAPIDocument loads and validates OpenAPI syntax without Sirius policy
// extensions (used for protected-baseline candidate syntax checks and fixtures).
func loadOpenAPIDocument(path string) error {
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = false
doc, err := loader.LoadFromFile(path)
if err != nil {
return fmt.Errorf("load openapi: %w", err)
}
if err := doc.Validate(loader.Context); err != nil {
return fmt.Errorf("validate openapi: %w", err)
}
return nil
}
// MustWriteTempSpec writes OpenAPI bytes to a temp file and returns its path.
func MustWriteTempSpec(dir, name string, raw []byte) (string, error) {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, raw, 0o644); err != nil {
return "", err
}
return path, nil
}
+5
View File
@@ -25,6 +25,11 @@ func ModuleRoot() string {
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
// RepoRoot returns the absolute path to the Sirius repository root.
func RepoRoot() string {
return filepath.Clean(filepath.Join(ModuleRoot(), ".."))
}
// NormalizePath converts Fiber `:param` segments to OpenAPI `{param}` segments.
func NormalizePath(path string) string {
if path == "" {
@@ -0,0 +1,167 @@
package contract
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
// EnvBaseRef selects the protected git ref/SHA that owns the authoritative
// OpenAPI baseline (merge-base / PR base / main). Candidate PRs cannot
// override this by editing a local baseline file.
EnvBaseRef = "SIRIUS_OPENAPI_BASE_REF"
// EnvAllowBreaking explicitly permits ERR-level oasdiff findings. This is
// intentionally separate from normal feature PRs and must be set only for
// an approved contract-break advancement (workflow_dispatch / human gate).
EnvAllowBreaking = "SIRIUS_OPENAPI_ALLOW_BREAKING"
// ProtectedOpenAPIRelPath is the authoritative published contract path in git.
ProtectedOpenAPIRelPath = "sirius-api/contracts/openapi.v1.yaml"
)
// BaselineResolution describes how the protected baseline was obtained.
type BaselineResolution struct {
Mode string // "protected", "bootstrap", "allow_breaking"
BaseRef string
BaselinePath string // filesystem path to baseline bytes for oasdiff
Message string
cleanup func()
}
// Cleanup removes any temporary files created while resolving the baseline.
func (r *BaselineResolution) Cleanup() {
if r != nil && r.cleanup != nil {
r.cleanup()
r.cleanup = nil
}
}
// ResolveProtectedBaseline loads openapi.v1.yaml from the protected git ref.
//
// Bootstrap (fail-open once): when the protected ref does not yet contain the
// contract file (this introductory PR), mode=bootstrap and baselinePath is empty.
// After the file exists on the protected base, resolution is fail-closed.
//
// Local checked-in openapi.v1.baseline.yaml is never used as the protected
// source — that would reintroduce candidate-controlled bypass.
func ResolveProtectedBaseline(repoRoot, baseRef string) (*BaselineResolution, error) {
repoRoot = filepath.Clean(repoRoot)
if baseRef == "" {
baseRef = strings.TrimSpace(os.Getenv(EnvBaseRef))
}
if baseRef == "" {
resolved, err := defaultMergeBase(repoRoot)
if err != nil {
return nil, err
}
baseRef = resolved
}
raw, err := gitShow(repoRoot, baseRef, ProtectedOpenAPIRelPath)
if err != nil {
if isMissingGitPath(err) {
return &BaselineResolution{
Mode: "bootstrap",
BaseRef: baseRef,
Message: fmt.Sprintf("protected ref %s has no %s; bootstrap mode (one-time until merged)", baseRef, ProtectedOpenAPIRelPath),
}, nil
}
return nil, err
}
tmpDir, err := os.MkdirTemp("", "sirius-openapi-baseline-*")
if err != nil {
return nil, err
}
baselinePath := filepath.Join(tmpDir, "openapi.v1.protected.yaml")
if err := os.WriteFile(baselinePath, raw, 0o644); err != nil {
_ = os.RemoveAll(tmpDir)
return nil, err
}
return &BaselineResolution{
Mode: "protected",
BaseRef: baseRef,
BaselinePath: baselinePath,
Message: fmt.Sprintf("using protected baseline from %s:%s", baseRef, ProtectedOpenAPIRelPath),
cleanup: func() { _ = os.RemoveAll(tmpDir) },
}, nil
}
// CheckProtectedBreaking compares the working-tree candidate against the
// protected baseline. Bootstrap mode skips oasdiff but still validates the
// candidate OpenAPI loads. allow_breaking requires EnvAllowBreaking=1.
func CheckProtectedBreaking(repoRoot, candidatePath, baseRef string) (*BaselineResolution, error) {
res, err := ResolveProtectedBaseline(repoRoot, baseRef)
if err != nil {
return nil, err
}
// Always ensure candidate is a syntactically valid OpenAPI document.
if err := loadOpenAPIDocument(candidatePath); err != nil {
return res, fmt.Errorf("candidate openapi invalid: %w", err)
}
if res.Mode == "bootstrap" {
return res, nil
}
if err := CompareOpenAPIBreaking(res.BaselinePath, candidatePath); err != nil {
if strings.TrimSpace(os.Getenv(EnvAllowBreaking)) == "1" {
res.Mode = "allow_breaking"
res.Message = "breaking changes permitted by " + EnvAllowBreaking + "=1"
return res, nil
}
return res, fmt.Errorf("%v\nnote: intentional breaks require a separate approved gate with %s=1; editing a local baseline file cannot bypass protected-base comparison", err, EnvAllowBreaking)
}
return res, nil
}
func defaultMergeBase(repoRoot string) (string, error) {
for _, remote := range []string{"origin/main", "main"} {
out, err := execGit(repoRoot, "rev-parse", "--verify", remote)
if err != nil {
continue
}
base := strings.TrimSpace(string(out))
mb, err := execGit(repoRoot, "merge-base", "HEAD", base)
if err != nil {
return base, nil
}
return strings.TrimSpace(string(mb)), nil
}
return "", fmt.Errorf("unable to resolve merge-base against origin/main or main; set %s", EnvBaseRef)
}
func gitShow(repoRoot, ref, path string) ([]byte, error) {
return execGit(repoRoot, "show", ref+":"+path)
}
func execGit(repoRoot string, args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repoRoot
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return out, nil
}
func isMissingGitPath(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "does not exist") ||
strings.Contains(msg, "exists on disk, but not in") ||
strings.Contains(msg, "path not in") ||
strings.Contains(msg, "fatal: path") ||
strings.Contains(msg, "bad object")
}
+81 -80
View File
@@ -2,112 +2,113 @@ package contract
import (
"fmt"
"net/http/httptest"
"strings"
"github.com/gofiber/fiber/v2"
)
// FindShadowedFixedRoutes reports fixed-path routes that are unreachable because
// an earlier same-method sibling parameterized route would match first.
// DeprecatedShadowAllowlist lists METHOD\tPATH entries that may remain shadowed
// for inventory fidelity. Policy: only exact duplicate registrations already
// classified deprecated (currently the second GET /host/source-coverage) may be
// allowlisted. Do not expand this list for new public/internal routes.
var DeprecatedShadowAllowlist = map[string]string{
"GET\t/host/source-coverage": "intentional duplicate registration retained for golden fidelity; first registration remains reachable",
}
// FindShadowedFixedRoutes reports routes that Fiber would dispatch to an earlier
// registration instead. Detection mounts pairs into a real Fiber app and probes
// a concrete path for the later route.
//
// Example: GET /api/v1/events/:id registered before GET /api/v1/events/stats
// shadows the stats handler.
// Covers final-segment params, mid-path params (/x/:id/detail vs /x/stats/detail),
// partially parameterized later routes, wildcards, and optional params.
func FindShadowedFixedRoutes(live []LiveRoute) []string {
type entry struct {
index int
method string
parts []string
raw string
}
entries := make([]entry, 0, len(live))
for i, route := range live {
parts := splitPath(route.Path)
entries = append(entries, entry{
index: i,
method: route.Method,
parts: parts,
raw: route.Method + "\t" + route.Path,
})
}
var findings []string
for i, later := range entries {
if hasParamSegment(later.parts) {
for i := 0; i < len(live); i++ {
later := live[i]
probe := materializeProbePath(later.Path)
if probe == "" {
continue
}
for j := 0; j < i; j++ {
earlier := entries[j]
if earlier.method != later.method {
earlier := live[j]
if earlier.Method != later.Method {
continue
}
if !sameParentPrefix(earlier.parts, later.parts) {
if !fiberEarlierWins(earlier, later, probe) {
continue
}
if len(earlier.parts) != len(later.parts) {
key := later.Method + "\t" + later.Path
if reason, ok := DeprecatedShadowAllowlist[key]; ok {
_ = reason
continue
}
if shadows(earlier.parts, later.parts) {
findings = append(findings, fmt.Sprintf(
"%s shadowed by earlier %s",
later.raw,
earlier.raw,
))
break
}
findings = append(findings, fmt.Sprintf(
"%s shadowed by earlier %s (probe %s)",
key,
earlier.Method+"\t"+earlier.Path,
probe,
))
break
}
}
return findings
}
func splitPath(path string) []string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return nil
func fiberEarlierWins(earlier, later LiveRoute, probe string) bool {
app := fiber.New()
hit := ""
app.Add(earlier.Method, earlier.Path, func(c *fiber.Ctx) error {
hit = "earlier"
return c.SendStatus(fiber.StatusNoContent)
})
app.Add(later.Method, later.Path, func(c *fiber.Ctx) error {
hit = "later"
return c.SendStatus(fiber.StatusNoContent)
})
req := httptest.NewRequest(later.Method, probe, nil)
resp, err := app.Test(req)
if err != nil {
return false
}
return strings.Split(trimmed, "/")
_ = resp.Body.Close()
return hit == "earlier"
}
func hasParamSegment(parts []string) bool {
// materializeProbePath turns a Fiber route pattern into a concrete URL that the
// later route intends to serve. Fixed segments are preserved; parameters become
// distinctive tokens unlikely to equal neighboring fixed names.
func materializeProbePath(pattern string) string {
if pattern == "" {
return ""
}
parts := strings.Split(pattern, "/")
out := make([]string, 0, len(parts))
paramIdx := 0
for _, part := range parts {
if isParamSegment(part) {
return true
}
}
return false
}
func isParamSegment(part string) bool {
return (strings.HasPrefix(part, ":") && len(part) > 1) ||
(strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") && len(part) > 2)
}
func sameParentPrefix(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return len(a) == len(b)
}
if len(a) != len(b) {
return false
}
for i := 0; i < len(a)-1; i++ {
if a[i] != b[i] {
return false
}
}
return true
}
func shadows(paramRoute, fixedRoute []string) bool {
if len(paramRoute) != len(fixedRoute) {
return false
}
sawParam := false
for i := range paramRoute {
if isParamSegment(paramRoute[i]) {
sawParam = true
if part == "" {
out = append(out, "")
continue
}
if paramRoute[i] != fixedRoute[i] {
return false
switch {
case part == "*":
out = append(out, "wildcard-leaf")
case strings.HasPrefix(part, ":") && strings.HasSuffix(part, "?"):
// Optional param: omit to exercise the optional-absent path.
continue
case strings.HasPrefix(part, ":") && len(part) > 1:
paramIdx++
out = append(out, fmt.Sprintf("p%d", paramIdx))
case strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") && len(part) > 2:
paramIdx++
out = append(out, fmt.Sprintf("p%d", paramIdx))
default:
out = append(out, part)
}
}
return sawParam
path := strings.Join(out, "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
+101
View File
@@ -0,0 +1,101 @@
package contract
import (
"strings"
"testing"
)
func TestShadowDetectorFiberMidPathParam(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/x/:id/detail"},
{Method: "GET", Path: "/x/stats/detail"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) != 1 {
t.Fatalf("findings=%v", findings)
}
if !strings.Contains(findings[0], "/x/stats/detail shadowed by") {
t.Fatalf("unexpected: %v", findings)
}
}
func TestShadowDetectorPartialParamLaterRoute(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/api/:version/items"},
{Method: "GET", Path: "/api/v1/items"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) == 0 {
t.Fatal("expected later fixed route to be shadowed by earlier param route")
}
}
func TestShadowDetectorDuplicateParamShapes(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/items/:id"},
{Method: "GET", Path: "/items/:name"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) != 1 {
t.Fatalf("duplicate param shapes should shadow later registration: %v", findings)
}
}
func TestShadowDetectorWildcard(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/files/*"},
{Method: "GET", Path: "/files/report"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) != 1 {
t.Fatalf("findings=%v", findings)
}
}
func TestShadowDetectorOptionalParam(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/items/:id?"},
{Method: "GET", Path: "/items"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) != 1 {
t.Fatalf("expected /items shadowed by optional param route, got %v", findings)
}
}
func TestShadowAllowlistsDeprecatedDuplicateHostSourceCoverage(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/host/source-coverage"},
{Method: "GET", Path: "/host/source-coverage"},
}
findings := FindShadowedFixedRoutes(live)
for _, f := range findings {
if strings.Contains(f, "/host/source-coverage") {
t.Fatalf("deprecated duplicate should be allowlisted: %s", f)
}
}
}
func TestShadowDetectorFinalSegment(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/api/v1/events/:id"},
{Method: "GET", Path: "/api/v1/events/stats"},
}
findings := FindShadowedFixedRoutes(live)
if len(findings) != 1 {
t.Fatalf("findings=%v", findings)
}
if !strings.Contains(findings[0], "/api/v1/events/stats shadowed by") {
t.Fatalf("unexpected finding: %s", findings[0])
}
}
func TestShadowDetectorDoesNotFalsePositiveDistinctPaths(t *testing.T) {
live := []LiveRoute{
{Method: "GET", Path: "/a/:id"},
{Method: "GET", Path: "/b/:id"},
}
if findings := FindShadowedFixedRoutes(live); len(findings) != 0 {
t.Fatalf("unexpected findings: %v", findings)
}
}
+2 -2
View File
@@ -175,11 +175,11 @@
"id": "3.2",
"title": "Route inventory and published OpenAPI for /api/v1",
"description": "Inventory and classify all current routes; publish a versioned OpenAPI spec; reserve /api/pro/v1 and /api/internal/v1 namespaces.",
"details": "Local implementation on feature/openapi-contract with review corrections: 74-route classification, OpenAPI 3.0.3 + semantic baseline breaking detection, reserved Pro/internal namespaces, production middleware shared by main/tests, exact GET /health auth bypass, fixed event/agent-template route shadowing, request/response accuracy fixes, Fiber utils.UUID request-ID semantics. TypeScript client deferred. Status remains in_progress until live CI accepts the branch.",
"details": "Local implementation on feature/openapi-contract with second-review corrections: protected merge-base/PR-base OpenAPI comparison (bootstrap until merged, fail-closed after; SIRIUS_OPENAPI_ALLOW_BREAKING gate); pinned oasdiff ERR/WARN + curated INFO checks with negative fixtures; Fiber-mounted shadow detector + deprecated duplicate allowlist; production middleware tests with in-process no-network logging sink; OpenAPI input/status accuracy (logs/events/trends/performance/snapshot/delete). TypeScript client deferred. Status remains in_progress until live CI accepts the branch.",
"status": "in_progress",
"priority": "high",
"dependencies": ["3.1"],
"testStrategy": "OpenAPI spec validates and is diffed in CI against the live route table; breaking-change detection fails a test PR that removes a field."
"testStrategy": "OpenAPI validates vs live route table; oasdiff rejects named breaking classes; protected-baseline bypass tests fail without allow gate; Fiber shadow + middleware no-network tests pass."
},
{
"id": "3.3",