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" "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) } 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") }