A pending device registration used to be announced to EVERY account's Devices page, and whoever clicked Approve first bound the device — and its freshly minted sync token — to their account. Worse, the pending card displayed the registration UUID, which is also the bearer credential on the unauthenticated /api/devices/register/status poll channel: any family user who could see the list already held everything needed to take the device. On a family server that is a wrong-account-binding bug (accidental or deliberate); this commit removes the class of problem rather than patching the race. The new model is the Netflix/YouTube TV device flow: the registering device displays a 6-digit pairing code, and the only person who can read it — whoever holds the device — types it into THEIR account on the Devices page. Possession of the device screen becomes the credential; nothing is broadcast; the reject-on-someone-else's-behalf hole dies with the list. Server changes: - POST /api/devices/register now mints pairing_code (crypto/rand, rejection-sampled for uniformity, collision-checked against live registrations) and retires prior unclaimed registrations with the same device_identifier, so re-registering leaves exactly one live attempt. - POST /api/devices/claim (JWT) binds the registration behind a code to the caller's account, reusing the rotate-or-create approval logic factored out of ApproveDevice (shared bindRegistrationToUser helper; the Android self-approval route keeps working unchanged). - POST /api/devices/claim/lookup (JWT) returns the confirm-card data without binding. Lookup and claim share a per-account budget (12 attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code space over a 5-minute TTL makes guessing infeasible without it. - POST /api/devices/register/cancel lets the registering device end its own attempt (registration UUID bearer, idempotent, never touches a claimed registration). - ListPendingRegistrations / GetPendingRegistrationsData are deprecated-by-emptiness: unclaimed registrations return to nobody. - RejectDevice and its route are deleted — the broadcast list was its only consumer, and there is nothing left for anyone to reject. Additive-only for pinned surfaces: the register response gains pairing_code (Android DTO untouched), the four KOReader sync surfaces and approve keep byte-identical shapes. contract_pins.md changelog records the widening and the reject removal. Docs, schema registry, and bruno mirrors move together per the drift gate; bruno_gen.go now keeps hand-authored bruno/podcasts/ (API docs never existed for them, so the next -update run would have swept them — latent landmine, defused). Unit tests: code shape, live-collision avoidance, typed-code normalization, and the resolve scopes (live/expired/claimed/miss).
99 lines
2.9 KiB
Go
99 lines
2.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestGeneratePairingCodeShape(t *testing.T) {
|
|
for i := 0; i < 50; i++ {
|
|
code, err := generatePairingCode()
|
|
require.NoError(t, err)
|
|
assert.Len(t, code, pairingCodeDigits)
|
|
assert.Regexp(t, `^\d{6}$`, code, "codes are 6 digits")
|
|
}
|
|
}
|
|
|
|
func TestGeneratePairingCodeAvoidsLiveCollisions(t *testing.T) {
|
|
reg := &PendingRegistration{RegistrationID: uuid.NewString(), PairingCode: "000000"}
|
|
pendingMu.Lock()
|
|
pendingRegistrations[reg.RegistrationID] = reg
|
|
pendingMu.Unlock()
|
|
t.Cleanup(func() {
|
|
pendingMu.Lock()
|
|
delete(pendingRegistrations, reg.RegistrationID)
|
|
pendingMu.Unlock()
|
|
})
|
|
|
|
for i := 0; i < 200; i++ {
|
|
code, err := generatePairingCode()
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, "000000", code, "mint must not collide with a live registration's code")
|
|
}
|
|
}
|
|
|
|
func TestNormalizePairingCode(t *testing.T) {
|
|
assert.Equal(t, "583912", normalizePairingCode("583 912"), "device shows spaced digits")
|
|
assert.Equal(t, "583912", normalizePairingCode("583-912"))
|
|
assert.Equal(t, "583912", normalizePairingCode("583912"))
|
|
assert.Equal(t, "583912", normalizePairingCode(" 58 39 12 "), "stray whitespace tolerated")
|
|
assert.Equal(t, "", normalizePairingCode("no digits here"))
|
|
}
|
|
|
|
func TestRegistrationByPairingCodeScopes(t *testing.T) {
|
|
live := &PendingRegistration{
|
|
RegistrationID: uuid.NewString(),
|
|
PairingCode: "111111",
|
|
DeviceName: "Live Device",
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
}
|
|
expired := &PendingRegistration{
|
|
RegistrationID: uuid.NewString(),
|
|
PairingCode: "222222",
|
|
ExpiresAt: time.Now().Add(-time.Minute),
|
|
}
|
|
claimed := &PendingRegistration{
|
|
RegistrationID: uuid.NewString(),
|
|
PairingCode: "333333",
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
Approved: true,
|
|
}
|
|
pendingMu.Lock()
|
|
for _, reg := range []*PendingRegistration{live, expired, claimed} {
|
|
pendingRegistrations[reg.RegistrationID] = reg
|
|
}
|
|
pendingMu.Unlock()
|
|
t.Cleanup(func() {
|
|
pendingMu.Lock()
|
|
for _, reg := range []*PendingRegistration{live, expired, claimed} {
|
|
delete(pendingRegistrations, reg.RegistrationID)
|
|
}
|
|
pendingMu.Unlock()
|
|
})
|
|
|
|
// Live code resolves.
|
|
got, err := registrationByPairingCode("111111")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Live Device", got.DeviceName)
|
|
|
|
// Expired code is reaped on sight.
|
|
_, err = registrationByPairingCode("222222")
|
|
assert.ErrorIs(t, err, errPairingExpired)
|
|
pendingMu.Lock()
|
|
_, stillThere := pendingRegistrations[expired.RegistrationID]
|
|
pendingMu.Unlock()
|
|
assert.False(t, stillThere, "expired registration must be reaped")
|
|
|
|
// Claimed code is refused.
|
|
_, err = registrationByPairingCode("333333")
|
|
assert.ErrorIs(t, err, errPairingClaimed)
|
|
|
|
// Unknown code is the generic miss.
|
|
_, err = registrationByPairingCode("999999")
|
|
assert.ErrorIs(t, err, errPairingInvalid)
|
|
}
|