fix(devices): pairing claim accepts urlencoded htmx form posts

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.
This commit is contained in:
John O'Keefe committed 2026-11-02 17:12:15 -05:00
1 parent d15f115505
commit 0c31e9b88d
2 files changed
+54 -2

No files matched your search

+50
View File
@@ -19,6 +19,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
@@ -91,6 +92,55 @@ func wrongPairingCode(code string) string {
return string(rotated)
}
// pairingFormRequest POSTs urlencoded form data — the Content-Type the
// browser's htmx forms actually send — with a Bearer token.
func pairingFormRequest(t *testing.T, setup *TestServerSetup, path, token, body string) (int, map[string]interface{}) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
return rec.Code, resp
}
// TestPairingClaimAcceptsBrowserFormEncoding pins the htmx path: the
// confirm card claims via a urlencoded <form>, so echo v5 routes the
// Content-Type to the `form:` binder, which maps by `form:` struct tag
// only. (Regression: PairingCodeRequest shipped with a json-only tag,
// leaving the code empty and every browser claim failing with 400 —
// "how did lookup find the device if the code is wrong?") The JSON path
// stays pinned by the tests above; this covers the form twin.
func TestPairingClaimAcceptsBrowserFormEncoding(t *testing.T) {
setup := setupTestServer(t)
identifier := fmt.Sprintf("pairing-form-%s", uuid.New().String())
reg := registerPairingDevice(t, setup, identifier)
code := reg["pairing_code"].(string)
// Form-encoded lookup (the same call the API-side confirm card makes).
status, resp := pairingFormRequest(t, setup, "/api/devices/claim/lookup",
setup.RegularToken, "pairing_code="+code)
require.Equal(t, http.StatusOK, status, "form-encoded lookup must resolve the live code")
assert.Equal(t, "Test Pairing Device", resp["device_name"])
// Form-encoded claim — exactly what the browser's Pair device
// button sends (urlencoded hidden input, not JSON).
status, resp = pairingFormRequest(t, setup, "/api/devices/claim",
setup.RegularToken, "pairing_code="+code)
require.Equal(t, http.StatusOK, status, "form-encoded claim must bind the device")
assert.Equal(t, true, resp["approved"])
// The device's own poll delivers credentials — binding really happened.
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
require.Equal(t, http.StatusOK, pollStatus)
assert.Equal(t, "approved", pollResp["status"])
}
func TestPairingClaimHappyPath(t *testing.T) {
setup := setupTestServer(t)
identifier := fmt.Sprintf("pairing-happy-%s", uuid.New().String())