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.
316 lines
14 KiB
Go
316 lines
14 KiB
Go
package main
|
|
|
|
// Pairing-code flow integration tests. The register response mints a
|
|
// 6-digit code shown on the registering device's screen; the person
|
|
// holding the device types it into THEIR account (POST /api/devices/claim).
|
|
// This replaces the retired broadcast-list approve, where
|
|
// first-click-wins let any account claim (or reject) a device it could
|
|
// see but not name.
|
|
//
|
|
// Devices claimed here land on the fresh per-run regular user, so the
|
|
// harness cascade removes them; identifiers are uuid-suffixed to stay
|
|
// clear of the UNIQUE(device_identifier) index across runs.
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// registerPairingDevice initiates a registration and returns its decoded
|
|
// response (registration_id, pairing_code, ...).
|
|
func registerPairingDevice(t *testing.T, setup *TestServerSetup, identifier string) map[string]interface{} {
|
|
t.Helper()
|
|
regRequest := map[string]interface{}{
|
|
"device_name": "Test Pairing Device",
|
|
"device_type": "koreader",
|
|
"device_identifier": identifier,
|
|
}
|
|
regBody, _ := json.Marshal(regRequest)
|
|
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
|
|
|
var regResponse map[string]interface{}
|
|
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
|
return regResponse
|
|
}
|
|
|
|
// pollRegistrationStatus hits the device-side status endpoint (no auth —
|
|
// the registration UUID is the bearer).
|
|
func pollRegistrationStatus(t *testing.T, setup *TestServerSetup, registrationID string) (int, map[string]interface{}) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
|
req := httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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
|
|
}
|
|
|
|
func pairingCodeBody(code string) []byte {
|
|
body, _ := json.Marshal(map[string]string{"pairing_code": code})
|
|
return body
|
|
}
|
|
|
|
// pairingLookupOrClaim POSTs to the given claim-family endpoint with the
|
|
// given bearer token and returns status + decoded body.
|
|
func pairingLookupOrClaim(t *testing.T, setup *TestServerSetup, path, token, code string) (int, map[string]interface{}) {
|
|
t.Helper()
|
|
req := httptest.NewRequest("POST", path, bytes.NewReader(pairingCodeBody(code)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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
|
|
}
|
|
|
|
// wrongPairingCode derives a code guaranteed to differ from the target's
|
|
// digits (rotate each digit +1 mod 10).
|
|
func wrongPairingCode(code string) string {
|
|
rotated := make([]byte, len(code))
|
|
for i := 0; i < len(code); i++ {
|
|
rotated[i] = byte('0' + (int(code[i]-'0')+1)%10)
|
|
}
|
|
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())
|
|
|
|
reg := registerPairingDevice(t, setup, identifier)
|
|
code, ok := reg["pairing_code"].(string)
|
|
require.True(t, ok, "register response must carry pairing_code")
|
|
require.Len(t, code, 6, "pairing code must be 6 digits")
|
|
for _, d := range code {
|
|
require.GreaterOrEqual(t, d, '0')
|
|
require.LessOrEqual(t, d, '9')
|
|
}
|
|
|
|
// Lookup first — the confirm card's data, without binding.
|
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, " "+code[:3]+" "+code[3:])
|
|
require.Equal(t, http.StatusOK, status, "lookup should resolve the live code (spaces normalized)")
|
|
assert.Equal(t, "Test Pairing Device", resp["device_name"])
|
|
assert.NotEmpty(t, resp["expires_in"])
|
|
|
|
// Still pending — lookup must not have bound anything.
|
|
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
|
require.Equal(t, http.StatusOK, pollStatus)
|
|
assert.Equal(t, "pending", pollResp["status"])
|
|
|
|
// Claim as the fresh regular user (deterministic account per doctrine).
|
|
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
|
require.Equal(t, http.StatusOK, status, "claim should bind the device")
|
|
assert.Equal(t, true, resp["approved"])
|
|
|
|
// The device's own poll now delivers credentials.
|
|
pollStatus, pollResp = pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
|
require.Equal(t, http.StatusOK, pollStatus)
|
|
assert.Equal(t, "approved", pollResp["status"])
|
|
token, ok := pollResp["auth_token"].(string)
|
|
require.True(t, ok, "approved poll must deliver the auth token")
|
|
require.NotEmpty(t, token)
|
|
|
|
// The device row belongs to the claimer, and the poll-delivered token
|
|
// matches the stored row.
|
|
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
|
pgtype.UUID{Bytes: [16]byte(getRegularUserID(t, setup.DB)), Valid: true})
|
|
require.NoError(t, err)
|
|
var found *database.Devices
|
|
for i := range devices {
|
|
if devices[i].DeviceIdentifier == identifier {
|
|
found = &devices[i]
|
|
}
|
|
}
|
|
require.NotNil(t, found, "device row must exist under the claimer")
|
|
assert.Equal(t, token, found.AuthToken, "poll-delivered token must match the stored row")
|
|
}
|
|
|
|
func TestPairingWrongCodeIsGenericAndNonDestructive(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
reg := registerPairingDevice(t, setup, fmt.Sprintf("pairing-miss-%s", uuid.New().String()))
|
|
code := reg["pairing_code"].(string)
|
|
|
|
// A wrong code fails with the generic message — no hint whether any
|
|
// registration is waiting.
|
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, wrongPairingCode(code))
|
|
require.Equal(t, http.StatusBadRequest, status)
|
|
assert.Equal(t, "invalid pairing code", resp["error"])
|
|
|
|
// The miss must not have killed or claimed the real registration.
|
|
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
|
require.Equal(t, http.StatusOK, status, "the correct code must still claim after a miss")
|
|
assert.Equal(t, true, resp["approved"])
|
|
}
|
|
|
|
func TestPairingClaimBindsClaimerOnly(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
identifier := fmt.Sprintf("pairing-scope-%s", uuid.New().String())
|
|
|
|
reg := registerPairingDevice(t, setup, identifier)
|
|
code := reg["pairing_code"].(string)
|
|
|
|
// The regular user claims it.
|
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
|
require.Equal(t, http.StatusOK, status)
|
|
|
|
// The admin (a different account) has no such device — and cannot
|
|
// re-claim the now-used code either.
|
|
adminDevices, err := setup.DB.ListDevicesByUser(context.Background(),
|
|
pgtype.UUID{Bytes: [16]byte(getTestUserID(t, setup.DB)), Valid: true})
|
|
require.NoError(t, err)
|
|
for _, d := range adminDevices {
|
|
assert.NotEqual(t, identifier, d.DeviceIdentifier,
|
|
"device must be bound to the claimer, not to every account")
|
|
}
|
|
|
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.Token, code)
|
|
assert.Equal(t, http.StatusConflict, status, "an already-used code must not bind again")
|
|
assert.Contains(t, resp["error"], "already used")
|
|
}
|
|
|
|
func TestPairingReRegisterRetiresPriorCode(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
identifier := fmt.Sprintf("pairing-retry-%s", uuid.New().String())
|
|
|
|
reg1 := registerPairingDevice(t, setup, identifier)
|
|
reg2 := registerPairingDevice(t, setup, identifier)
|
|
|
|
code1, ok := reg1["pairing_code"].(string)
|
|
require.True(t, ok)
|
|
code2, ok := reg2["pairing_code"].(string)
|
|
require.True(t, ok)
|
|
require.NotEqual(t, code1, code2, "each registration mints its own code")
|
|
|
|
// The retired attempt is gone from both the code and UUID channels.
|
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
|
|
assert.Equal(t, http.StatusBadRequest, status, "retired code must not claim")
|
|
pollStatus, _ := pollRegistrationStatus(t, setup, reg1["registration_id"].(string))
|
|
assert.Equal(t, http.StatusNotFound, pollStatus, "retired registration must not poll")
|
|
|
|
// The fresh code claims fine.
|
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
|
|
require.Equal(t, http.StatusOK, status)
|
|
assert.Equal(t, true, resp["approved"])
|
|
}
|
|
|
|
func TestPairingCancelByRegistrationUUID(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
cancelled := registerPairingDevice(t, setup, fmt.Sprintf("pairing-cancel-%s", uuid.New().String()))
|
|
kept := registerPairingDevice(t, setup, fmt.Sprintf("pairing-keep-%s", uuid.New().String()))
|
|
|
|
// Cancel only the first attempt — bearer is its own registration UUID.
|
|
body, _ := json.Marshal(map[string]string{"registration_id": cancelled["registration_id"].(string)})
|
|
req := httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
require.Equal(t, http.StatusOK, rec.Code, "cancel should succeed")
|
|
|
|
// Cancelled: code dead, status poll gone.
|
|
code1 := cancelled["pairing_code"].(string)
|
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
|
|
assert.Equal(t, http.StatusBadRequest, status, "cancelled code must not claim")
|
|
pollStatus, _ := pollRegistrationStatus(t, setup, cancelled["registration_id"].(string))
|
|
assert.Equal(t, http.StatusNotFound, pollStatus, "cancelled registration must not poll")
|
|
|
|
// Idempotent: cancelling again is still a 200.
|
|
req = httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec = httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "cancel must be idempotent")
|
|
|
|
// The other registration is untouched by its neighbour's cancel.
|
|
code2 := kept["pairing_code"].(string)
|
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
|
|
require.Equal(t, http.StatusOK, status, "cancel must scope to its own registration only")
|
|
assert.Equal(t, true, resp["approved"])
|
|
}
|
|
|
|
func TestPairingAttemptRateLimit(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
|
|
// The regular user is fresh per setup, so the limiter key
|
|
// (userID|ip) is this test's alone — the budget cannot have been
|
|
// pre-spent by sibling tests sharing the process-global limiter.
|
|
// Burn the budget with bad lookups (12/min); the 13th attempt must
|
|
// be refused before the code check.
|
|
for i := 0; i < 12; i++ {
|
|
code := fmt.Sprintf("%06d", i*7919+13)
|
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, code)
|
|
require.Equal(t, http.StatusBadRequest, status, "attempt %d should fail as a plain miss", i+1)
|
|
}
|
|
|
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, "999999")
|
|
assert.Equal(t, http.StatusTooManyRequests, status, "13th attempt must hit the budget")
|
|
assert.Contains(t, resp["error"], "too many")
|
|
}
|