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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user