Repository navigation
fix(api): detect duplicate registrations by SQLSTATE, not error text - #1019
Conversation
CreateAccountRegistration classified insert failures by substring-matching "duplicate key" / the index name in the driver error and returned a hand-built "duplicate: ..." string, which the register handler then matched with strings.Contains(err.Error(), "duplicate"). A reference_token collision was misreported as an existing registration (409), any error whose text said "duplicate" became a 409, and a translated or reformatted server message would turn a routine duplicate into a 500. The store now uses errors.As to find the *pgconn.PgError and switches on SQLSTATE 23505 plus the constraint name, returning ErrDuplicatePendingRegistration for idx_account_registrations_pending_unique and ErrReferenceTokenCollision for account_registrations_reference_token_key, both wrapping the driver error. Every other error stays a generic insert failure. The handler maps the duplicate sentinel to the same 409 and message as before via errors.Is, regenerates the token and retries (bounded) on a token collision, and lets everything else surface as a generic 500. Closes LeanerCloud#137 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 5 billable files and costs up to $1.25. Or wait 59 minutes for your next included review. View limit details
Comment |
Independent review verdict: CLEAN at 7829b80Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, acting as a security reviewer. The PR body was treated as a claim. Coordinator: claude-cc-reviewer. Blocking findings: none. Constraints that can fire on insert (migration 000017):
The PR matches 23505 plus Wrap chain (no
Enumeration: the 409 text and status are unchanged, so nothing is newly exposed. The token-collision case no longer returns a misleading "already exists", which is better. The public endpoint still reveals that a pending registration exists; that is existing behaviour, noted only. Remaining text matcher (outside this PR's scope, as its body says): What was attacked and how:
Gate:
|
|
Gate record for 7829b80 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at 7829b80 with --match-head-commit. |
|
Gate record for 7829b80 (origin/main CLAUDE.md "Review gate"):
Merging at 7829b80 with --match-head-commit. |
Root cause
POST /api/registerdecided "this account already has a pending registration" by matching error text in two places:PostgresStore.CreateAccountRegistrationchecked whether the driver error text containedidx_account_registrations_pending_uniqueorduplicate key, then returned a newfmt.Errorf("duplicate: ...")that did not wrap the driver error.submitRegistrationthen checkedstrings.Contains(err.Error(), "duplicate").As a result:
reference_tokencollision (account_registrations_reference_token_key) also containsduplicate key, so it came back as 409 "a pending registration already exists for this account". That answer is wrong, and the collision can be fixed by retrying.duplicatewas reported as a 409.lc_messages) or reformatted driver message would stop matching. A routine duplicate would then become a 500.Fix
internal/config/store_postgres_registrations.go:classifyRegistrationInsertErroruseserrors.Asto find the*pgconn.PgErrorand switches on SQLSTATE23505andConstraintName:idx_account_registrations_pending_uniquereturnsErrDuplicatePendingRegistration.account_registrations_reference_token_keyreturnsErrReferenceTokenCollision.Both wrap the driver error with
%w, so thePgErrorcan still be reached. Every other error, including a unique violation on any other constraint, staysfailed to create account registration: %w. The constraint names are constants next to the sentinels, so a third unique constraint on the table cannot silently fall into an existing branch. This follows the pattern already ininternal/auth/store_postgres.go(isEmailDuplicateError).internal/api/handler_registrations.go: the newinsertRegistrationuseserrors.Is:ErrDuplicatePendingRegistrationreturns the same 409 with the same message as before, so the API contract is unchanged, and no admin email is sent.ErrReferenceTokenCollisionregenerates the token and re-inserts, up to 3 attempts. The response now returns the token that was actually stored.handleRequestErroras a generic500 {"error":"Internal server error"}. The DB error is only logged, never returned to the caller.Closes #137
Verification
All test commands ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, on macOS (darwin/arm64), Go toolchain fromgo.mod.store_postgres_registrations_test.go, pgxmock):PgError23505 on the pending index returns the duplicate sentinel, and thePgErroris still reachable witherrors.As.account_registrations_pkey, another SQLSTATE that names the pending index, or a plainerrors.Newwhose text is the exact Postgres duplicate-key message.handler_registrations_submit_test.go):Internal server error, send no email and make one store call: an error whose text contains "duplicate", a 23505 on another constraint, and a differentPgError.store_postgres_registrations_integration_test.go, testcontainers and the real migrations):(provider, external_id)twice returnsErrDuplicatePendingRegistration, and the realPgErrorcarries code 23505 and constraintidx_account_registrations_pending_unique.ErrReferenceTokenCollision. This confirms the real constraint name isaccount_registrations_reference_token_key.pending, the same account can register again.strings.Contains(err.Error(), "duplicate")restoredgo build ./...,go vet ./...andgo vet -tags integration ./...are clean.go test ./internal/api/ ./internal/config/ -count=1andgo test ./internal/mocks/ -count=1(repo-wide guards) pass.golangci-lintv2.10.1 reports 0 issues over./..., both with and without--build-tags integration.gocyclo -over 10on the changed non-test files reports nothing.git-secrets --scanwith the AWS patterns, run on a temporary copy with an isolated HOME, is clean.bash scripts/test-git-secrets-allowlist.shgives 42 PASS, 0 FAIL.go test -v -tags integration ./internal/config/ -count=1passed (635 tests passed, 799s). 11 tests were skipped because they need a dedicated test database URL in the environment (for exampleLADDER_ARMING_TEST_DATABASE_URL). None of them is related to registrations. Theinternal/apiintegration suite was not run, because this change adds no integration tests there.Out of scope (follow-ups)
These are other places that branch on error-message text, found by grepping the repo. They are not on the registration path and are left unchanged:
internal/api/handler_accounts.go:263isDuplicateKeyError: matches"duplicate key"/"23505"inerr.Error(). Audit A02-019.internal/api/handler_config.go:210mergeServiceConfig: matches"not found". Audit A02-019 calls this the most important one.internal/api/handler_auth.go:304isResetPasswordClientError: matches a list of message fragments. Audit A02-019.internal/auth/store_postgres.goalready uses a typed check (SQLSTATE 23505 plus constraint name). It is listed here only as the pattern to copy.Also not addressed here: the fail-open
registerrate-limit bucket and the per-replica in-memory limiter (LeanerCloud/cloud-commitments-cli#1575). The issue lists these as compounding factors, not as part of this defect.🤖 Generated with Claude Code