test(devices): integration coverage for the pairing-code flow

Replaces the reject-route test (behavior removed) with coverage of the
replacement flow, run against the shared dev DB per the doctrine:
fresh per-run regular user for deterministic claims, uuid-suffixed
identifiers so UNIQUE(device_identifier) stays clear across runs, and
device rows cascade away with the fresh user — testuser is untouched.

- TestPairingClaimHappyPath: lookup normalizes spaced digits, does not
  bind; claim binds to the claimer only; the device's status poll then
  delivers a token matching the stored row.
- TestPairingWrongCodeIsGenericAndNonDestructive: a miss answers the
  generic invalid-code error and leaves the real code claimable.
- TestPairingClaimBindsClaimerOnly: the admin account has no device
  row for another user's claim, and the used code answers 409.
- TestPairingReRegisterRetiresPriorCode: the old code and its
  registration UUID both die; the fresh code claims.
- TestPairingCancelByRegistrationUUID: cancel is idempotent, kills only
  its own registration (code and poll), and leaves a sibling
  registration untouched.
- TestPairingAttemptRateLimit: 13th lookup/claim attempt in a minute is
  refused with 429 (uses the fresh regular user — the limiter is
  process-global, so sibling tests sharing a token would otherwise
  pre-spend the budget).
- TestListPendingRegistrations now pins the invisibility invariant: a
  live registration appears in no account's pending list, total 0.
- TestRejectDeviceRegistrationRemoved pins the reject route's removal
  (404/405).
This commit is contained in:
John O'Keefe committed 2026-11-02 16:11:25 -05:00
1 parent fef79b1836
commit 3e98fadb9f
2 files changed
+298 -14

No files matched your search

+33 -14
View File
@@ -237,10 +237,25 @@ func TestDeviceAuthentication(t *testing.T) {
func TestListPendingRegistrations(t *testing.T) {
setup := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/devices/pending", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
// A live registration must NOT appear in any account's pending list:
// the broadcast is what let any user claim (or reject) someone else's
// device. Pairing codes replaced it — see device_pairing_test.go.
regRequest := map[string]interface{}{
"device_name": "Broadcast Test Device",
"device_type": "koreader",
"device_identifier": fmt.Sprintf("broadcast-test-%s", uuid.New().String()),
}
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")
req = httptest.NewRequest("GET", "/api/devices/pending", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations")
@@ -248,8 +263,9 @@ func TestListPendingRegistrations(t *testing.T) {
json.Unmarshal(rec.Body.Bytes(), &response)
pending, ok := response["pending_registrations"].([]interface{})
assert.True(t, ok, "Should have pending_registrations array")
assert.NotNil(t, pending, "Pending registrations should not be nil")
require.True(t, ok, "Should have pending_registrations array")
assert.Empty(t, pending, "Unclaimed registrations must be invisible to every account")
assert.Equal(t, float64(0), response["total"], "Total must stay zero while a registration is live")
}
// Regression test for the "approve returns 500 after app reinstall" bug:
@@ -370,13 +386,18 @@ func TestApproveDeviceRegistration(t *testing.T) {
assert.True(t, approveResponse["approved"].(bool), "Should confirm approval")
}
func TestRejectDeviceRegistration(t *testing.T) {
// TestRejectDeviceRegistrationRemoved pins the removal: the reject route is
// gone because unclaimed registrations are invisible since pairing codes
// took over — there is nothing left for anyone to reject. See
// device_pairing_test.go for the replacement flow (cancel by the
// registering device itself).
func TestRejectDeviceRegistrationRemoved(t *testing.T) {
setup := setupTestServer(t)
regRequest := map[string]interface{}{
"device_name": "Test Device for Rejection",
"device_type": "koreader",
"device_identifier": "test-rejection-12345",
"device_identifier": fmt.Sprintf("test-rejection-%s", uuid.New().String()),
}
regBody, _ := json.Marshal(regRequest)
@@ -385,25 +406,23 @@ func TestRejectDeviceRegistration(t *testing.T) {
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
var regResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &regResponse)
registrationID, ok := regResponse["registration_id"].(string)
assert.True(t, ok, "Should have registration_id")
require.True(t, ok, "Should have registration_id")
req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should reject device registration")
var rejectResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &rejectResponse)
assert.Equal(t, "device registration rejected", rejectResponse["message"], "Should confirm rejection message")
// 404 = no route, 405 = path-shaped collision without POST — either
// proves the reject surface is gone.
assert.True(t, rec.Code == http.StatusNotFound || rec.Code == http.StatusMethodNotAllowed,
"Reject route must not exist (registration invisible, nothing to reject); got %d", rec.Code)
}
func TestRegenerateDeviceToken_Success(t *testing.T) {