diff --git a/cmd/server/tests/device_pairing_test.go b/cmd/server/tests/device_pairing_test.go index bfa1afb3..384af391 100644 --- a/cmd/server/tests/device_pairing_test.go +++ b/cmd/server/tests/device_pairing_test.go @@ -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
, 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()) diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go index ffe2d77f..472ed558 100644 --- a/internal/handlers/devices.go +++ b/internal/handlers/devices.go @@ -104,7 +104,9 @@ type DeviceApprovalRequest struct { // PairingCodeRequest carries the 6-digit code shown on a registering // device's screen — the claim credential for the pairing flow. type PairingCodeRequest struct { - PairingCode string `json:"pairing_code"` + // form tag mirrors json: htmx posts urlencoded, the API posts JSON — + // echo v5's form binder maps by `form:` tag only (auth.go convention). + PairingCode string `form:"pairing_code" json:"pairing_code"` } // PairingLookupResponse describes a live registration without binding it — @@ -117,7 +119,7 @@ type PairingLookupResponse struct { } type CancelRegistrationRequest struct { - RegistrationID string `json:"registration_id" validate:"required"` + RegistrationID string `form:"registration_id" json:"registration_id" validate:"required"` } type PendingRegistration struct {