Skip to content

fix(api): document every routed path in openapi.yaml and guard against drift - #1100

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-262
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-262

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Nothing checked internal/api/openapi.yaml against router.go, so routes were added without spec entries and documented operations drifted from their handlers. On main, 49 registered routes had no operation. Because TestOpenAPI403OnPermissionGatedRoutes only checks routes the spec lists, those routes were also exempt from the 403 guard.

Merged PRs #772 and #778 mention "#262", but they mean cloud-commitments-go#262 and neither touches the spec. Some items in the issue were already fixed on main and are not redone here: /api/recommendations/freshness and POST /api/purchases/revoke/{id} are documented, and approve/cancel are POST-only with the body token documented (#911).

Fix

  1. New guard, TestOpenAPIDocumentsEveryRoute (internal/api/openapi_route_coverage_test.go). For every spec operation it fills each {param} with a sample value and runs it through the live router's first-match logic (Router.matches over registerRoutes(), in order). It fails in two directions:

    • a route that no documented operation reaches;
    • a documented operation that reaches no route.

    Multi-segment parameters and prefix/suffix ordering therefore behave exactly as in production. There are two exemption lists, and each entry must give a reason:

    An exemption that is no longer needed also fails the test, so the lists can't go stale. TestRouteCoverageGapsDetectsDrift and TestPartitionExemptReportsStaleEntries check that the checker itself catches drift in both directions.

  2. Backfill. All 49 operations, read from the handlers: parameters actually read, request/response shapes, and the status codes the handler can return. New tags: Accounts, Registrations, Federation, Inventory, Ladder, Notifications. RIExchange was already used but never declared, so it is now declared. Docs get HEAD operations and the /docs/openapi.yaml alias the Swagger page fetches.

  3. Corrected operations from the issue:

    • The shared StartDate/EndDate parameters are now start/end. Every handler reads those names, and the frontend sends them; start_date/end_date were never read.
    • /api/history: add provider, account_ids and limit; drop interval; start/end are YYYY-MM-DD.
    • /api/history/analytics: add interval and provider, plus the 400/404/503 responses.
    • /api/history/breakdown: add account to the dimension enum.
    • /api/dashboard/summary: drop the unread date range; add account_ids.
    • /api/recommendations: add account_ids, min_savings_usd and min_savings_pct; drop the unread account_id.
    • /api/recommendations/refresh: returns 200 RefreshResponse and can return 409. The unused CollectResult schema is removed.
    • PUT /api/config and the service config PUT return {"status":"updated"}.
    • /api/config/service/{service} is now /api/config/service/{provider}/{service}. GET returns {} rather than 404.
    • setup-admin gets 409/503, login gets 503, and reset-password gets 409/429/503.
    • The spec is served as application/yaml.
  4. 403 guard fixes (openapi_403_test.go):

    • specPathsForRoute now maps each operation to the route the router actually dispatches it to, not to every route whose prefix matches.
    • requirePermission mentions inside comments are ignored.
    • A length check makes sure the source parse of router.go matches the live route table.

    Without these changes, documenting GET /api/purchases/revoke/{id} (the public HTML form) wrongly demanded a 403 from the GET /api/purchases/{id} handler. GET /api/auth/me/permissions was also flagged, because of a comment.

Verification

Unit/fixture evidence only. No server was run and no cloud call was made. Tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.

  • Fail-before. The new guard against main's spec fails with 49 undocumented routes: recommendation detail, plan accounts, approve details, revoke GET/retry/revoke/calculate/marketplace, trends, me/permissions, reset-password/status, 4x MFA, 11x accounts, 2x inventory, 6x ladder, 2x unsubscribe, register and 5x registrations, federation/iac, /version, and HEAD on /api/docs and /docs.
  • Pass-after. go test ./internal/api/ -run 'TestOpenAPI|TestRouteCoverage|TestPartitionExempt' -count=1 passes.
  • Old 403 matcher against the new spec. Main's openapi_403_test.go fails with exactly the two false positives above (GET /api/purchases/revoke/{id}, GET /api/auth/me/permissions). The updated test passes.
  • Package tests and guards. go test ./internal/api/ -count=1 and go test ./internal/mocks/ -count=1 pass. go build ./... and go vet ./internal/api/ are clean.
  • Lint. golangci-lint v2.10.1 run ./internal/api/ reports 0 issues.
  • Complexity. gocyclo -over 10 -ignore "_test\.go" reports nothing. Only test files changed; the two pre-existing hits in openapi_403_test.go are unchanged functions.
  • Secrets. git-secrets --scan on the changed files, run in an isolated repo with the CI patterns and .gitallowed, is clean. scripts/test-git-secrets-allowlist.sh gives 42 PASS.
  • OpenAPI validation. redocly lint (1.25, minimal rules) reports the same 2 pre-existing errors as main (tracked in fix(openapi): correct two schema definitions for the declared 3.0.3 dialect #1075, not touched). The added warnings are tag-description warnings and no-ambiguous-paths (for example /api/purchases/approve/{id} vs /api/purchases/{id}/revoke), which is inherent to the router's prefix/suffix URL scheme.
  • Reading the diff. GitHub's default diff makes the YAML look heavily rewritten. git diff --diff-algorithm=histogram origin/main shows 42 deleted lines, all intended edits; the rest are additions.

Out of scope (found while reading handlers, not changed here)

  • Unsupported analytics values return 500. interval/dimension reach the store unvalidated and come back as 500 instead of 400.
  • PATCH skips CSRF validation. The CSRF middleware only checks POST/PUT/DELETE, so PATCH /api/ladder/tranches/{id} (and PATCH /api/plans/{id}) are never CSRF-validated. The spec still lists the header, by convention.
  • Registration approve can leave an orphan. It marks the registration approved before creating the account, so a 409/500 on create leaves an approved registration with no account.
  • PUT /api/accounts/{id} drops the provider. It validates but does not persist provider, yet echoes it back.
  • Possible Azure revoke 500 (not verified). POST /api/purchases/{id}/revoke with an Azure ARM purchase_id probably fails the UUID execution_id lookup with a 500.
  • The email revoke form's POST always returns 401. It sends no Authorization or CSRF header.
  • Stale code comments. "202" in the refresh handler, "207" for RECONCILE_PENDING (the API sends 200), and the old /revoke/calculate/{id} path.
  • Audit finding A02-012 is not addressed. /api/api-keys/{id} and /api/users/{id} returning 500 where the spec says 404 is a code bug, not spec drift.
  • Expected conflicts with open PRs.

Maintainer: please mirror the issue's triage labels onto this PR: priority/p2, severity/medium, type/chore, triaged, urgency/this-quarter, impact/few, effort/m.

Closes #262

🤖 Generated with Claude Code

…t drift

openapi.yaml had no entry for 49 registered routes (accounts, registrations,
MFA, inventory, ladder, purchase retry/revoke/marketplace, plan accounts,
trends, unsubscribe, federation IaC, /version, docs HEAD), so they were
invisible to API consumers and exempt from the 403 permission guard.

- Add TestOpenAPIDocumentsEveryRoute: replays the live router's first-match
  dispatch for every spec operation and fails when a route has no
  operation or an operation reaches no route. Stale exemptions also fail.
- Backfill the missing operations and their schemas.
- Correct documented operations that disagreed with their handlers:
  start/end (not start_date/end_date) on history and analytics,
  history provider/account_ids/limit (and no interval), analytics
  interval/provider, the breakdown account dimension, dashboard
  account_ids (no date range), recommendations account_ids and
  min_savings_* (no account_id), the refresh response and 409,
  config PUT status bodies, the provider/service config path,
  auth 409/429/503 codes and the application/yaml spec content type.
- Make TestOpenAPI403OnPermissionGatedRoutes map each operation to the
  route the router actually dispatches it to, and ignore comment mentions
  of requirePermission, so documenting GET /api/purchases/revoke/{id} and
  GET /api/auth/me/permissions does not demand a 403 they never return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(api): openapi.yaml omits ~30 routed paths and mis-describes documented operations

1 participant