package onboarding import ( "strings" "sync" "testing" "time" "data-terminal-cloud/internal/identity" "github.com/google/uuid" ) func (f *fixture) bleToken() string { f.t.Helper() r, err := f.s.CreateBLEToken(f.ctx, f.user, BLETokenInput{DeviceID: f.d.FactoryID, ElderID: f.elder}) must(f.t, err) return r["bind_token"].(string) } func TestBLEBindingLifecycle(t *testing.T) { f := newFixture(t) first := f.bleToken() token := f.bleToken() if first == token || !validB64(token, 32) { t.Fatal("token must be fresh and contain 32 random bytes") } r, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: first}) must(t, err) if r["status"] != "failed" { t.Fatal("replaced token remained valid") } _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: first}) if err == nil { t.Fatal("replaced token accepted") } r, err = f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token}) must(t, err) if r["status"] != "pending" { t.Fatal(r) } _, err = f.s.BLEStatus(f.ctx, identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString()}, BLEBindInput{Token: token}) if err == nil { t.Fatal("another account read the binding result") } r, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) if r["status"] != "success" { t.Fatal(r) } _, err = f.s.pool.Exec(f.ctx, `UPDATE device_ble_binding SET expires_at=now()-interval '1 minute' WHERE token_hash=$1`, digest([]byte(token))) must(t, err) // A lost response must be recoverable even after the token's original TTL. r, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) if r["status"] != "success" { t.Fatal(r) } var count int must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid`, f.d.ID).Scan(&count)) if count != 1 { t.Fatalf("duplicate bindings: %d", count) } f.refresh() if f.d.Generation != 1 { t.Fatalf("generation %d", f.d.Generation) } _, err = f.s.CreateBLEToken(f.ctx, f.user, BLETokenInput{DeviceID: f.d.FactoryID, ElderID: f.elder}) if err == nil { t.Fatal("already-bound device admitted a new claim") } } func TestBLEBindingRejectsExpiredAndWrongDevice(t *testing.T) { f, other := newFixture(t), newFixture(t) token := f.bleToken() _, err := f.s.BindBLE(f.ctx, other.d, uuid.NewString(), BLEBindInput{Token: token}) if err == nil { t.Fatal("token accepted by another device") } _, err = f.s.CreateBLEToken(f.ctx, f.user, BLETokenInput{DeviceID: f.d.FactoryID, ElderID: other.elder}) if err == nil { t.Fatal("foreign subject accepted") } _, err = f.s.pool.Exec(f.ctx, `UPDATE device_ble_binding SET expires_at=$2 WHERE token_hash=$1`, digest([]byte(token)), time.Now().Add(-time.Minute)) must(t, err) _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) if err == nil { t.Fatal("expired token bound device") } _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: strings.Repeat("x", 44)}) if err == nil { t.Fatal("malformed token accepted") } } func TestBLEConcurrentRetryIsIdempotent(t *testing.T) { f := newFixture(t) token := f.bleToken() var wg sync.WaitGroup errs := make(chan error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() _, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) errs <- err }() } wg.Wait() close(errs) for err := range errs { must(t, err) } var count int must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid`, f.d.ID).Scan(&count)) if count != 1 { t.Fatalf("concurrent bind created %d bindings", count) } }