The browser's Pair device button submits an application/x-www-form-
urlencoded <form> (htmx), but PairingCodeRequest/CancelRegistration-
Request carried only json: tags. Echo v5 routes form posts to the
'form:' binder, which maps by the form: struct tag exclusively — the
code arrived empty, normalized to "", and every browser claim failed
with the generic 'invalid pairing code' 400 even though the separate
lookup step (which reads c.FormValue directly, no struct) had just
resolved the same code. The API clients (plugin, Android, tests) post
JSON and were never affected.
Both request structs now carry dual form:/json: tags, matching the
auth.go convention. Adds TestPairingClaimAcceptsBrowserFormEncoding to
pin the form path: form-encoded lookup resolves, form-encoded claim
binds, device poll delivers credentials.
Found by the user clicking through the real Add Device modal; the
integration suite missed it because every prior claim test posted
application/json.
Replaces the reject-route test (behavior removed) with coverage of the
replacement flow, run against the shared dev DB per the doctrine:
fresh per-run regular user for deterministic claims, uuid-suffixed
identifiers so UNIQUE(device_identifier) stays clear across runs, and
device rows cascade away with the fresh user — testuser is untouched.
- TestPairingClaimHappyPath: lookup normalizes spaced digits, does not
bind; claim binds to the claimer only; the device's status poll then
delivers a token matching the stored row.
- TestPairingWrongCodeIsGenericAndNonDestructive: a miss answers the
generic invalid-code error and leaves the real code claimable.
- TestPairingClaimBindsClaimerOnly: the admin account has no device
row for another user's claim, and the used code answers 409.
- TestPairingReRegisterRetiresPriorCode: the old code and its
registration UUID both die; the fresh code claims.
- TestPairingCancelByRegistrationUUID: cancel is idempotent, kills only
its own registration (code and poll), and leaves a sibling
registration untouched.
- TestPairingAttemptRateLimit: 13th lookup/claim attempt in a minute is
refused with 429 (uses the fresh regular user — the limiter is
process-global, so sibling tests sharing a token would otherwise
pre-spend the budget).
- TestListPendingRegistrations now pins the invisibility invariant: a
live registration appears in no account's pending list, total 0.
- TestRejectDeviceRegistrationRemoved pins the reject route's removal
(404/405).