Repository navigation
fix(api): answer a missing RI exchange like a wrong token on approve and reject - #1175
Conversation
…and RI exchange Approve and cancel already answer an emailed-token action with 403 for an invalid token, 410 for an expired one and 409 for a wrong status. Revoke answered an expired link with 409, and read the expiry before matching the token. The RI exchange approve and reject links never checked expires_at: the transition CAS refused an expired or already-moved record with a plain error, so both came back as 500. - validateRevokeToken reuses purchase.ValidateApprovalToken: token matched first, then 410 for an expired link, then the revocation window (403). - checkExchangeToken gives RI approve and reject the same 403/410 order. - TransitionRIExchangeStatus wraps ErrRIExchangeExpired and ErrRIExchangeNotInExpectedStatus; riExchangeTransitionError maps them to 410 and 409 on the approve (token and session) and reject paths. - openapi.yaml documents the 410s and the 409 race. Closes LeanerCloud#587 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and reject The RI exchange approve and reject email links are open to callers with no session, since the token alone authorizes them. They answered a wrong token on an existing exchange with 403 but a missing exchange id with 404 (with the in-memory mocks) or 500 (on Postgres, whose GetRIExchangeRecord returned a bare error for no row), so anyone could probe which exchange ids exist. A 401 before the lookup, the purchase routes' fix (LeanerCloud#435), does not fit: it would break the token-only email flow. Instead token mode now gives one answer to every caller who does not hold the token: - checkExchangeToken treats a missing record and a record without a token like a wrong one: 403 "invalid approval token" / "invalid rejection token". The LeanerCloud#587 order is unchanged: the token match comes first, then 410 for an expired exchange, then the transition's 409/410. - PostgresStore.GetRIExchangeRecord wraps ErrNotFound for no row. getRIExchangeRecord maps it (or a nil record) to "missing", so the session approve path answers a missing exchange with the documented 404 instead of 500. Other store errors stay 500. - openapi.yaml: 404 is session mode only on approve and is gone from reject; the 403s say an unknown id answers like a wrong token. Closes LeanerCloud#601 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Missing-record lookup contract internal/config/interfaces.go, internal/config/store_postgres.go, internal/config/store_postgres_pgxmock_test.go |
GetRIExchangeRecord wraps ErrNotFound when a record is missing. Tests distinguish this from query failures. |
Token route lookup and validation internal/api/handler_ri_exchange.go |
A shared lookup helper maps ErrNotFound to a nil record. Token validation returns 403 for a missing record or token mismatch. Session-authorized lookup retains its 404 behavior. |
Response contract and regression coverage internal/api/openapi.yaml, internal/api/*ri_exchange*test.go, internal/api/coverage_extras_test.go, internal/api/router_handlers_test.go |
Documentation and tests cover matching responses for missing records and invalid tokens, including session-mode approval and requests that leave records unchanged. |
Priority:
Estimated code review effort: 3 (Moderate) | ~25 minutes
Change: Bug fix · Severity of issue fixed: Low
Suggested reviewers: cristim
Merge Risk: 
Minimal · up to 6903a
-
The documented token responses appear consistent for missing exchanges and invalid tokens. No merge-blocking issue is established.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 warning Passed checks: 4 passed-
Full details: Linked Issues check
-
Explanation
#601is the only active directly linked issue. The handler now gives token-mode approve and reject requests one 403 response for a missing record, a record without a token, and a wrong token. The implementation preserves token-only authorization. The session approve path maps a missing record to 404. The added HandleRequest regression tests compare missing and wrong-token responses, including status, body, and headers. The OpenAPI definitions document the shared 403 response and the session-only 404.#587is closed and supplies historical context only.
Full details: Out of Scope Changes check
-
Explanation
The changed handler logic, Postgres ErrNotFound mapping, interface documentation, OpenAPI updates, and unit, mock-store, and integration tests support
#601's response-consistency objective. The session approve 404 fix prevents a related Postgres lookup error from producing 500. No unrelated implementation, schema change, or database write is shown.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 9 files. (1 skipped: 1 unsupported.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches
-
🧪 Generate unit tests (beta)
-
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
|
Independent review 2 (claude-fleet, cold): APPROVE 8999bc2 Scope: the head is stacked on #1172 (fea837d is not on origin/main), so the full merge-base..head diff was reviewed with the focus on fea837d..8999bc2. The head merges cleanly onto current origin/main. No migration. Findings (none blocking; related low-severity notes were shared privately with the maintainers per SECURITY.md):
Against #601: on Postgres, a missing id used to answer 500, because a bare error made the nil branch at handler.go dead, while an existing id answered 403. The fix maps ErrNotFound to nil at a single choke point, and Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE 8999bc2 Scope reviewed: the full diff Findings (none blocking):
Issue #601 acceptance: met. One status and one body for missing, no-token and wrong-token records in token mode. A regression test runs through HandleRequest for no session, an invalid session and a session without approve rights. openapi.yaml is updated. The PR's choice not to answer 401 is argued correctly: the token alone authorizes this route. Attacked:
|
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rB24. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
Conflict in validateRevokeToken: LeanerCloud#1242 (on main) moved the token match ahead of the expiry and window checks but kept 409 for an expired link; this branch routes revoke through purchase.ValidateApprovalToken for the 403/410 mapping. Kept this branch's switch, which already matches the token (constant time, against the stored digest) before the expiry and the window, and merged the two doc comments. LeanerCloud#1242's test for that order (TestHandleRequest_RevokeViaEmail_WrongTokenOnLapsedRowIsInvalid) now expects 410 for the right token on an expired link, matching approve and cancel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e 409 text Review follow-ups on LeanerCloud#1172. riExchangeTransitionError turns the store's error, which names the exchange and its current status, into a bare ClientError, and handleRequestError never logs ClientErrors, so a lost approve/reject race or a mid-flight expiry left no server log line. Log the store error at info level in the 409 and 410 branches. Expiry is answered with 410 now, so the 409 conflict messages no longer list "expired" as a cause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts in internal/api/handler_purchases.go and its test come only from LeanerCloud#1172's commit, which this branch is stacked on; this branch does not touch those files. Took LeanerCloud#1172's resolution of the same merge (c1919cc): its 403/410 validateRevokeToken switch, which keeps LeanerCloud#1242's token-first order, and LeanerCloud#1242's order test expecting 410 for an expired link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bring in LeanerCloud#1172's new head (main merge plus its review follow-ups) so the two stacked PRs agree. The one conflict is the RI approve/reject status table: kept this branch's LeanerCloud#601 rows (missing record answers 403 like a wrong token in token mode) with LeanerCloud#1172's 409 text, which no longer lists "expired". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged current main (c8bb869) and #1172's new head (c85ef50) into the branch, so the two stacked PRs agree. New head: Resolution
Checks on the merged tree, with no cloud credentials:
#1172 should merge first. If it does, this PR's diff shrinks to its own commit. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rC5. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rC6. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
LeanerCloud#1172, which this branch carries, merged as squash 89e1140. For both conflicted files (internal/api/handler_ri_exchange.go and ri_exchange_approve_reject_status_test.go), main's content is byte-identical to LeanerCloud#1172's head c85ef50 and hasn't changed since, so the conflicts are only this branch's own additions on top: kept this branch's side. The diff against main is now only LeanerCloud#1175's own change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged origin/main (now |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rC8. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rC7. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
|
Independent review 1, delta since 8999bc2 (cold, fresh context): VERDICT: APPROVE 6903a8e Findings:
Conflict resolutions (git show --remerge-diff):
Oracle check of the whole PR, from a caller that does not hold the token:
Attacked:
None. I found no way for a caller without the token to tell a missing id, a wrong token, expiry or a lost race apart from the HTTP status, body or headers. |
|
Independent review 2 (claude-fleet, cold): APPROVE at 6903a8e Context: PR #1175 has already landed on main as the squash 2a11540 ("... (#1175)"). Blocking findings: none. Non-blocking findings:
Response comparison (token mode, reject and approve; X = approval or rejection): The other reviewer's three claims:
Should a DB error look like a wrong token? No. A primary-key lookup fails for reasons unrelated to the ID (connection, timeout), so a 500 reveals nothing about which IDs exist. Hiding it as a 403 would mask outages, against fail-loud. Probe M6 shows the tests enforce this. Entry points:
Merge resolution check:
Probes (head export; each restored, cmp OK, the three source files also match git show):
Commands (GOTOOLCHAIN=go1.26.10, AWS config and credential files set to /dev/null):
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
…RI exchange approve (#1454) A session holding only approve-own on purchases reached fetchAndAuthorizeRIExchange, which answered a missing exchange id with 404 and another user's non-pending exchange with 409 (naming its status) before running the ownership check. That let the caller learn, for any id, whether the exchange exists and read its status, even for exchanges it did not create: the session-path half of the oracle #1175 closed for token-only callers. Run authorizeSessionApproveRIExchange right after the lookup and before the 404/409 answers. For an approve-own session a missing record now gets the same 403 as another user's record, so it also falls through to the email token like any other ownership refusal. The admin key and approve-any sessions may approve any exchange, so they keep 404/409, as does approve-own on an exchange the caller created. A table test through HandleRequest pins one answer for a missing id and for another user's pending, completed, failed and expired exchanges, with and without a token. openapi.yaml documents the change. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
POST /api/ri-exchange/approve/{id}and/reject/{id}areAuthPublic: the emailed token alone authorizes them, with no session. In token mode they gave different answers for an existing and a missing exchange id to a caller with a wrong token:invalid approval tokeninvalid approval tokenthis exchange record does not support approvalinvalid approval tokenexchange record not foundinvalid approval token(Same for reject with
rejection.) On Postgres the oracle was 500 vs 403, becausePostgresStore.GetRIExchangeRecordreturns a bare error for no row, never(nil, nil), so the handler'srecord == nil -> 404branch was dead and the missing id fell into "failed to look up exchange record". That is also why a session approve of a missing exchange returned 500 although openapi.yaml says 404.Decision: one answer, not a 401
The issue asks whether to answer 401 before the lookup, as the purchase routes do (#435/#584/#589/#594). That does not fit here: on the purchase routes the token never authorizes on its own, so every caller needs a session anyway. On the RI exchange routes the token alone approves or rejects (the pure email-client flow,
transitioned_byNULL), so a 401 would break the documented flow. Instead, token mode now answers every caller who does not hold the token the same way: 403 with the same body for a wrong token, a record with no token, and a missing id. That keeps #587's token mapping as it is (#1172: 403 invalid, then 410 expired once the token matches, then the transition's typed 409/410, all before the 4-eyes check).Fix (commit after fea837d)
checkExchangeToken(from fix(api): answer expired and lost-race token actions alike on revoke and RI exchange #1172) takes a possibly nil record: a nil record, or one without a token, gets the same 403 as a wrong token. The separate "does not support approval/rejection" text is gone;ApprovalTokenMatchesnever matches an empty stored digest, so it needed no extra branch.PostgresStore.GetRIExchangeRecordwrapsconfig.ErrNotFoundfor no row (documented onStoreInterface). A newHandler.getRIExchangeRecordmapsErrNotFound, or a nil record from the other store implementations, to "missing". Every other store error stays a 500.fetchAndAuthorizeRIExchange) uses the same helper, so a missing exchange there is the documented 404 on Postgres instead of 500. Session callers have already passed CSRF, the scope check and the approve-* RBAC pre-check, so this 404 is not open to unauthenticated callers.No DB writes or schema changes. The only store change is the error a read returns for a missing row, so there is no new concurrency surface. #1172's race test (
TestPostgresStoreDB_TransitionRIExchangeStatus_ConcurrentLosersGetNotInExpectedStatus) still passes.Verification
All local, on macOS, run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. No cloud calls: the integration test only rejects, and the approve probes are all refused before execution.Regression tests (fail before, pass after):
internal/api/ri_exchange_approve_reject_status_test.go(unit, throughHandleRequestwith mock store and auth):TestHandleRequest_RIExchangeTokenRoutes_MissingAnswersLikeWrongToken: for approve and reject, and for no session, an invalid session, and a session without approve permission (which falls through to the token), it compares status, body and headers of a missing id (nil record, and a wrappedErrNotFoundas Postgres reports it) against a pending record with a wrong token, a record with no token, and an expired record with a wrong token. Before the fix all 36 subtests failed; after, all pass.riRecMissingNotFound(missing as Postgres reports it): token mode gives 403, and session approve gives 404 (500 before).internal/api/ri_exchange_token_oracle_integration_test.go(-tags integration, real Postgres 16 via testcontainers, realPostgresStore, throughHandleRequest):TestRIExchangeTokenRoutes_Integration_MissingAnswersLikeWrongToken: a missing id against pending, no-token and expired rows, for approve and reject. Before: missing id 500Internal server errorvs existing 403. After: identical 403 responses, rows leftpending, plus a control where the real token holder's reject still returns 200.TestApproveRIExchange_Integration_SessionMissingIs404: before 500, after 404exchange record not found.internal/config/store_postgres_pgxmock_test.go:TestPGXMock_GetRIExchangeRecord_NotFound(no row givesErrorIs ErrNotFound) and..._QueryErrorIsNotErrNotFound(a failed query is notErrNotFound).coverage_extras_test.go,handler_ri_exchange_test.go,router_handlers_test.go).Commands run:
Fail-before evidence came from reverting only
internal/api/handler_ri_exchange.goand rerunning the same tests.Not covered: no deployed or real-AWS run. Timing still differs slightly between a missing id (one indexed query) and an existing one (query plus a SHA-256 and a constant-time compare). That is microseconds against network and DB jitter, so it is not addressed here.
Out of scope (noted, not changed)
fetchAndAuthorizeRIExchange, a signed-in session holding approve-own gets 409cannot be approved (status=...)for a non-pending exchange before the creator check runs, so it learns another user's exchange status. That is the session path for authenticated callers, not the unauthenticated missing-vs-existing oracle in sec(api): RI-exchange approve/reject distinguish a missing record (404) from an existing one (403 wrong token) for unauthenticated callers #601, so it is left for a separate issue.Maintainer: please mirror the issue's triage labels on this PR:
triaged,priority/p3,severity/low,urgency/eventually,impact/few,effort/s,type/bug.Closes #601
🤖 Generated with Claude Code
Summary by CodeRabbit