Skip to content

fix(api): 404 for missing users/groups/API keys, 400 for bad read filters (#312) - #1267

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

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

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Works through the four low-severity internal/api findings bundled in #312 (2026-09-02 audit). I re-checked each one against current main (a14dc8f). Before touching a function, I diffed it against every in-flight branch: open PRs #1111, #1116, #1141, #1212, #1237, #1241, #1242, #1243, #1244, and the unopened fix/issue-532, fix/issue-54, fix/issue-169, fix/issue-310, fix/issue-337 on the IAmJSD fork.

Refs #312

This is a partial fix (hence Refs). A02-007 is deferred to #1141, and the A01-021 dashboard copy needs the UI owner.

Per-finding status

Finding Status on main This PR
A01-021: cancelling one upcoming purchase disables its whole plan, but the docs say the plan stays intact Still present. Since #774 (and the scope work in #520/#623) the cancel deliberately disables the plan and returns plan_disabled: true. The UpcomingPurchase Go doc and the dashboard confirm dialog still say the plan stays in place. Backend half fixed: the UpcomingPurchase contract comment in internal/api/types.go now describes the real behavior. This only changes a comment, so it has no fail-before test; the existing delete_planned_purchase_scope_test.go tests already pin the disable behavior. The UI copy is deferred to the UI owner (see "Frontend follow-up"). I kept the #774 behavior rather than dropping the disable, because #774/#520/#623 chose it on purpose. Reversing it is an owner decision.
A02-007: approveRegistration commits approved before the account insert Still present on main. Deferred to #1141 (fix/issue-1102). That branch rewrites exactly these lines of approveRegistration: it moves the transition, the account insert and the link into a single ApproveAccountRegistration transaction. Editing them here would conflict.
A02-012: a missing user, group or API key returns 500 Still present. GET /api/users/{id} gets the raw pgx.ErrNoRows; GET /api/groups/{id} gets raw pgx.ErrNoRows from scanGroup or a plain "group not found"; API key delete/revoke get plain-string errors. All of them become 500 plus an error-level log line, while openapi.yaml already documents 404. Fixed. See below.
A02-021: enumerated query params are not validated on the dashboard, inventory and accounts reads Still present (provider=AWS or enabled=1 → 200 with an empty or unfiltered result). Fixed. See below.

No in-flight branch edits the lines changed here. The nearest overlaps:

I put the new tests in new files so they can't conflict with branches that edit the existing test files.

Root cause and fix

A02-012. internal/auth had no not-found sentinels, and IsNotFoundError only matches the router's own type, so handleRequestError treated every not-found as a server failure.

  • internal/auth/errors.go adds four sentinels:
    • ErrUserNotFound and ErrGroupNotFound. These wrap pgx.ErrNoRows when the store returned it, so callers that check errors.Is(err, pgx.ErrNoRows) keep working.
    • ErrAPIKeyNotFound.
    • ErrAPIKeyNotOwned. Its message is unchanged: unauthorized: cannot <action> another user's API key.
  • These now return the sentinels: Service.GetUser, Service.GetGroupAPI, authorizeAPIKeyAccess (behind delete and revoke), and the server adapter's nil-user branch.
  • Handler mapping:
    • mapAuthError (used by getUser) maps ErrUserNotFound to 404 "user not found".
    • getGroup maps ErrGroupNotFound to 404 "group not found".
    • The new mapAPIKeyAccessError maps both ErrAPIKeyNotFound and ErrAPIKeyNotOwned to the same 404 "API key not found", so a non-admin can't tell another user's key ID from a nonexistent one.
    • Any other error still returns a 500.

A02-021. These handlers forwarded params["provider"] without validation. buildAccountFilter silently ignored any enabled value other than true/false.

  • The new providerFilterParam runs validateProvider and maps all to "no filter". validateProvider already accepts all as "every provider"; before this change these reads filtered on the literal string "all" and returned nothing.
  • getDashboardSummary, fetchCommitmentRecords (behind /api/inventory/commitments and /api/inventory/coverage) and getCoverageBreakdown now use it before any store read.
  • buildAccountFilter (behind GET /api/accounts and /api/accounts/list) now returns (filter, error). It rejects unknown providers and any enabled other than true/false/empty with 400.
  • openapi.yaml adds the 400 response to /api/dashboard/summary. The inventory and accounts list endpoints aren't in the spec.

The frontend only sends aws|azure|gcp|'' and enabled=true|false (frontend/src/api/{dashboard,inventory,accounts}.ts), so the UI is unaffected.

No DB or schema change and no new concurrency surface, so no race test applies. The store already returns pgx.ErrNoRows for a missing user, group or key (scanUser/scanGroup/scanAPIKey; store_postgres_test.go pins the group case), so the not-found paths are covered with mocks at the service and handler layers.

Verification

All of this is unit/mock evidence with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true, Go 1.26.9. No real cloud or HTTP traffic: the internal/api no-network guard was active.

Fail-before (run in TDD order: tests written first, then the fix):

  • go test -count=1 -run 'TestService_GetUser_MissingUserIsErrUserNotFound|TestService_GetGroupAPI_MissingGroupIsErrGroupNotFound|TestService_APIKeyAccess_NotFoundAndNotOwnedSentinels' ./internal/auth/. This ran with only the sentinel declarations added. Result: FAIL, Target error should be in err chain: expected: "user not found" (and the same for group, API key and unauthorized, 8 assertions).
  • go test -count=1 -run '^TestAuthServiceAdapter_GetUser_MissingUserIsErrUserNotFound$' ./internal/server/. This ran with app.go and service_user.go reverted to main. Result: FAIL in both subtests (store_returns_ErrNoRows, store_returns_nil_user).
  • go test -count=1 -run 'TestHandler_getUser_MissingUserIs404|TestHandler_getGroup_MissingGroupIs404|TestHandler_APIKeyDeleteRevoke_' ./internal/api/. Result: FAIL with:
    • must be a client error, not a 500: user not found: no rows in result set
    • ... group not found
    • ... failed to delete API key: API key not found
    • ... failed to revoke API key: unauthorized: cannot delete another user's API key
  • Each A02-021 test was run alone (-run '^<name>$' ./internal/api/) on the pre-fix handlers:
    • TestHandler_getDashboardSummary_RejectsUnknownProvider: FAIL. provider=AWS reached ListRecommendations (mock: I don't know what to return because the method call was unexpected).
    • TestHandler_getDashboardSummary_AllProviderMeansNoFilter: FAIL. ListRecommendations was called with Provider:"all".
    • TestHandler_inventory_RejectsUnknownProvider: FAIL. The store was read with provider=AWS.
    • TestHandler_listActiveCommitments_AllProviderMeansNoFilter: FAIL. "[]" should have 2 item(s), but has 0.
    • TestHandler_getCoverageBreakdown_AllProviderMeansNoFilter: FAIL. provider=all must read the same as no provider.
    • TestListAccounts_RejectsUnknownEnumParams: FAIL. An error is expected but got nil for enabled=1|yes|TRUE, provider=AWS|ibm|"aws ", on both /api/accounts and /api/accounts/list.
    • TestListAccounts_AcceptedFilterValues: FAIL. provider=all produced a non-nil provider filter.

Pass-after on the branch head:

  • All of the tests above pass, plus the "store failure stays 500" negative controls (TestHandler_getUser_StoreFailureStays500, TestHandler_getGroup_StoreFailureStays500, TestHandler_APIKeyDeleteRevoke_StoreFailureStays500).
  • Package runs, one at a time:
    • go test -count=1 ./internal/api/ → ok
    • go test -count=1 ./internal/auth/ → ok
    • go test -count=1 ./internal/server/ → ok
    • go test -count=1 ./internal/mocks/ → ok. This includes the vacuous-mock-assertion guard, which caught two matcher-less AssertNotCalled calls in my first draft; both are fixed.
  • go build ./..., go vet ./... and go vet -tags integration ./... → clean.
  • golangci-lint run --allow-parallel-runners ./internal/api/ ./internal/auth/ ./internal/server/ (v2.10.1) → 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" on every changed non-test file → nothing over 10 (mapAuthError is 5, buildAccountFilter is 6).

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope / notes

  • Other plain-string "API key not found" errors remain in internal/auth/service_apikeys_api.go (around lines 425 and 474), on paths the audit didn't flag. I left them alone.
  • updateUser/deleteUser for a missing user weren't in the finding and keep their current behavior. mapAuthError now also maps ErrUserNotFound, but only getUser produces it today.
  • External API clients (CLI, MCP) that send a provider other than aws|azure|gcp|all, or enabled other than true|false, to these reads now get 400 instead of a silently empty or unfiltered 200. I could not check those repos from here.
  • When TestListAccounts_Success (an existing test) runs alone, the internal/api no-network guard reports blocked IMDS calls from markSelfAccount. The same happens on main. It passes in the full package run. Unrelated to this change; my new account tests pre-resolve the source identity so they never make that call.

Frontend follow-up for the UI owner

A01-021: frontend/src/dashboard.ts cancelScheduledPurchase still tells the user: "Cancelling removes this upcoming step from the plan. The plan itself stays in place; the next scheduled step (if any) is unaffected." The comment above it says the same. Since #774 the backend also disables the plan, so no further purchases happen until someone re-enables it on the Plans page. The response carries plan_disabled: true. Suggested change:

  • Reword the confirm body to say the plan will be disabled.
  • Optionally, use plan_disabled in the success toast, e.g. "Purchase cancelled; plan disabled".

If the owner would rather the cancel not disable the plan, that reverses #774/#520/#623 and should be a separate issue.

Issue #312 can close once #1141 (A02-007) and the UI copy change above have both landed.

Maintainers: please mirror the issue's triage labels onto this PR: triaged, priority/p3, severity/low, urgency/eventually, impact/few, effort/m, type/bug.

🤖 Generated with Claude Code

…ters

Three of the four low findings in the 2026-09-02 audit issue, re-verified
on current main:

- A02-012: GET /api/users/{id}, GET /api/groups/{id} and DELETE / revoke
  of an API key answered 500 for a missing row (raw pgx.ErrNoRows or an
  untyped error) where openapi.yaml documents 404. internal/auth now
  returns ErrUserNotFound, ErrGroupNotFound (both still match
  pgx.ErrNoRows), ErrAPIKeyNotFound and ErrAPIKeyNotOwned, and the
  handlers map them to 404. A non-admin acting on another user's key gets
  the same 404 as a missing key.
- A02-021: the dashboard summary, both inventory reads and both account
  lists accepted any provider (and any "enabled") and answered 200 with
  an empty or unfiltered result. They now validate with validateProvider
  (400, as on recommendations/history) and treat provider "all" as no
  filter; enabled must be true or false.
- A01-021: the UpcomingPurchase doc said canceling one upcoming purchase
  leaves its plan active, but since LeanerCloud#774 the cancel also disables the
  plan. The Go contract comment now says so; the dashboard confirm copy
  is left to the UI owner.

A02-007 (approveRegistration ordering) is left to the in-flight fix on
fix/issue-1102 (LeanerCloud#1141), which rewrites the same lines.

Refs LeanerCloud#312

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/few Limited audience effort/m Days type/bug Defect labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 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/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant