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:
1 parent
d15f115505
commit
0c31e9b88d
2 files changed
+54
-2
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user