package onboarding import ( "bytes" "context" "errors" "fmt" "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/google/uuid" ) func transferTarget(t *testing.T, f *fixture) string { t.Helper() id := uuid.NewString() _, err := f.s.pool.Exec(f.ctx, `INSERT INTO subject_profile(public_id,tenant_id,household_id,preferred_name,status) SELECT $1,tenant_id,household_id,'另一位老人','active' FROM subject_profile WHERE public_id=$2`, id, f.elder) must(t, err) return id } func transferInput(t *testing.T, f *fixture, target string) BLETokenInput { t.Helper() var binding string must(t, f.s.pool.QueryRow(f.ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1 AND unbound_at IS NULL`, f.d.ID).Scan(&binding)) return BLETokenInput{DeviceID: f.d.FactoryID, ElderID: target, ExpectedBindingID: binding} } func transferUnchanged(t *testing.T, f *fixture, binding string, generation int64) { t.Helper() var current string var gen int64 must(t, f.s.pool.QueryRow(f.ctx, `SELECT b.public_id::text,d.binding_generation FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL WHERE d.id=$1`, f.d.ID).Scan(¤t, &gen)) if current != binding || gen != generation { t.Fatalf("original binding changed: %s / %d", current, gen) } } func TestBLETransferRequiresPhysicalDeviceAndReplays(t *testing.T) { f := newFixture(t) f.bound() f.web.Hash = "" target := transferTarget(t, f) in := transferInput(t, f, target) session := webBLESession(t, f, f.user) status, result := webBLERequest(t, f.s, "ble-bind-tokens", session, webBLECSRF, "https://platform.example", in) if status != 200 { t.Fatalf("issue token %d %v", status, result) } token := result["bind_token"].(string) transferUnchanged(t, f, in.ExpectedBindingID, 1) r, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token}) must(t, err) if r["status"] != "pending" { t.Fatal(r) } // Browser authentication is not enough to commit; the device signature is required. unsigned := httptest.NewRequest("POST", "https://platform.example/api/v1/device/ble-bind", bytes.NewReader(requestJSON(BLEBindInput{Token: token}))) unsigned.Header.Set("X-Request-ID", uuid.NewString()) unsigned.Header.Set("Content-Type", "application/json") unsigned.AddCookie(&http.Cookie{Name: f.s.cookieName(true), Value: session}) mux := http.NewServeMux() f.s.RegisterRoutes(mux) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, unsigned) if recorder.Code != 401 { t.Fatalf("unsigned confirmation: %d", recorder.Code) } transferUnchanged(t, f, in.ExpectedBindingID, 1) status, result = f.deviceCall("ble-bind", BLEBindInput{Token: token}, "", true) if status != 200 { t.Fatalf("device confirmation %d %v", status, result) } var elder string var generation int64 must(t, f.s.pool.QueryRow(f.ctx, `SELECT sp.public_id::text,d.binding_generation FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL JOIN subject_profile sp ON sp.id=b.subject_id WHERE d.id=$1`, f.d.ID).Scan(&elder, &generation)) if elder != target || generation != 3 { t.Fatalf("target %s generation %d", elder, generation) } // Concurrent retries after a lost response cannot create a second transfer. 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 total int must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1`, f.d.ID).Scan(&total)) if total != 2 { t.Fatalf("binding rows %d", total) } r, err = f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token}) must(t, err) if r["status"] != "success" { t.Fatal(r) } } func TestBLETransferRejectsInvalidTargetsAndStaleSource(t *testing.T) { f, other := newFixture(t), newFixture(t) f.bound() in := transferInput(t, f, transferTarget(t, f)) for _, invalid := range []BLETokenInput{ {DeviceID: f.d.FactoryID, ElderID: in.ElderID}, {DeviceID: f.d.FactoryID, ElderID: in.ElderID, ExpectedBindingID: uuid.NewString()}, {DeviceID: f.d.FactoryID, ElderID: f.elder, ExpectedBindingID: in.ExpectedBindingID}, {DeviceID: f.d.FactoryID, ElderID: other.elder, ExpectedBindingID: in.ExpectedBindingID}, {DeviceID: f.d.FactoryID, ExpectedBindingID: in.ExpectedBindingID}, } { if _, err := f.s.CreateBLEToken(f.ctx, f.user, invalid); err == nil { t.Fatalf("invalid transfer accepted: %+v", invalid) } transferUnchanged(t, f, in.ExpectedBindingID, 1) } if _, err := f.s.CreateBLEToken(f.ctx, other.user, in); err == nil { t.Fatal("foreign user got token") } r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) token := r["bind_token"].(string) if _, err = f.s.BindBLE(f.ctx, other.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil { t.Fatal("wrong device accepted") } // An intervening release invalidates the pending transfer permanently. _, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1 AND unbound_at IS NULL`, f.d.ID) must(t, err) if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil { t.Fatal("stale source accepted") } } func TestBLETransferRechecksPermissionAndExpiry(t *testing.T) { for _, change := range []string{"role", "target", "expiry"} { t.Run(change, func(t *testing.T) { f := newFixture(t) f.bound() in := transferInput(t, f, transferTarget(t, f)) r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) token := r["bind_token"].(string) switch change { case "role": _, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET role='viewer' WHERE user_id=$1`, f.web.UserID) case "target": _, err = f.s.pool.Exec(f.ctx, `UPDATE subject_profile SET status='suspended' WHERE public_id=$1`, in.ElderID) case "expiry": _, 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) if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil { t.Fatal("changed authorization accepted") } transferUnchanged(t, f, in.ExpectedBindingID, 1) }) } } func TestBLETransferRechecksDirectCallAndOTABeforeCommit(t *testing.T) { f := newFixture(t) f.bound() in := transferInput(t, f, transferTarget(t, f)) r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) token := r["bind_token"].(string) _, err = f.s.pool.Exec(f.ctx, `INSERT INTO call_session(device_id,tenant_id,boot_id,hook_event_id,call_generation,binding_generation) VALUES($1,$2,$3,'transfer-guard',1,1)`, f.d.ID, f.tenant, uuid.NewString()) must(t, err) for _, phase := range []string{"PREPARING", "STARTING", "TALKING", "STOPPING"} { _, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state=$2 WHERE device_id=$1`, f.d.ID, phase) must(t, err) if _, err = f.s.CreateBLEToken(f.ctx, f.user, in); err == nil { t.Fatalf("issued while %s", phase) } if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil { t.Fatalf("committed while %s", phase) } transferUnchanged(t, f, in.ExpectedBindingID, 1) } _, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state='ENDED' WHERE device_id=$1`, f.d.ID) must(t, err) var release string must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO fleet_firmware(tenant_id,model,version,size,sha256,object_ref,status) VALUES($1,$2,'transfer-guard',32,$3,$4,'READY') RETURNING id::text`, f.tenant, f.d.Model, digest([]byte("test")), "test://"+uuid.NewString()).Scan(&release)) _, err = f.s.pool.Exec(f.ctx, `INSERT INTO fleet_ota(device_id,release_id,request_id) VALUES($1,$2,$3)`, f.d.ID, release, uuid.NewString()) must(t, err) for _, phase := range []string{"pending", "accepted"} { _, err = f.s.pool.Exec(f.ctx, `UPDATE fleet_ota SET status=$2 WHERE device_id=$1`, f.d.ID, phase) must(t, err) if _, err = f.s.CreateBLEToken(f.ctx, f.user, in); err == nil { t.Fatalf("issued while OTA %s", phase) } if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil { t.Fatalf("committed while OTA %s", phase) } transferUnchanged(t, f, in.ExpectedBindingID, 1) } _, err = f.s.pool.Exec(f.ctx, `UPDATE fleet_ota SET status='failed' WHERE device_id=$1`, f.d.ID) must(t, err) _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) } func TestBLETransferInsertionFailureRollsBackOriginalBinding(t *testing.T) { f := newFixture(t) f.bound() in := transferInput(t, f, transferTarget(t, f)) r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) token := r["bind_token"].(string) var targetID string must(t, f.s.pool.QueryRow(f.ctx, `SELECT id::text FROM subject_profile WHERE public_id=$1`, in.ElderID).Scan(&targetID)) // Fault only this generated target in the disposable integration database. constraint := "test_transfer_" + uuid.NewString()[:8] _, err = f.s.pool.Exec(f.ctx, fmt.Sprintf(`ALTER TABLE device_binding ADD CONSTRAINT %s CHECK(subject_id<>'%s'::uuid)`, constraint, targetID)) must(t, err) defer f.s.pool.Exec(f.ctx, `ALTER TABLE device_binding DROP CONSTRAINT `+constraint) if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil { t.Fatal("expected injected insertion failure") } transferUnchanged(t, f, in.ExpectedBindingID, 1) _, err = f.s.pool.Exec(f.ctx, `ALTER TABLE device_binding DROP CONSTRAINT `+constraint) must(t, err) _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) } func TestBLETransferReusesVoiceIdentityAndFencesOldReceipt(t *testing.T) { f := serverFixture(t) firstToken := f.bleToken() first, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: firstToken}) must(t, err) f.web.Hash = "" in := transferInput(t, f, transferTarget(t, f)) r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) second, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)}) must(t, err) if first["device_secret"] != second["device_secret"] || first["device_name"] != second["device_name"] || f.registrar.calls.Load() != 1 { t.Fatal("transfer changed provider identity or registered twice") } if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: firstToken}); err == nil { t.Fatal("old elder receipt remains usable") } } func TestBLETransferRechecksSourcePermissionIndependently(t *testing.T) { f := newFixture(t) f.bound() var house, target string must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO household(tenant_id,name,owner_user_id) VALUES($1,'目标家庭',$2) RETURNING id::text`, f.tenant, f.web.UserID).Scan(&house)) _, err := f.s.pool.Exec(f.ctx, `INSERT INTO household_member(tenant_id,household_id,user_id,role,status) VALUES($1,$2,$3,'admin','active')`, f.tenant, house, f.web.UserID) must(t, err) must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO subject_profile(tenant_id,household_id,preferred_name,status) VALUES($1,$2,'目标老人','active') RETURNING public_id::text`, f.tenant, house).Scan(&target)) in := transferInput(t, f, target) r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) _, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET role='viewer' WHERE user_id=$1 AND household_id<>$2`, f.web.UserID, house) must(t, err) if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)}); err == nil { t.Fatal("target permission bypassed revoked source permission") } transferUnchanged(t, f, in.ExpectedBindingID, 1) } func TestBLETransferSerializesWithInFlightCallPreparation(t *testing.T) { f := newFixture(t) f.bound() in := transferInput(t, f, transferTarget(t, f)) r, err := f.s.CreateBLEToken(f.ctx, f.user, in) must(t, err) tx, err := f.s.pool.Begin(f.ctx) must(t, err) defer tx.Rollback(f.ctx) // This is the transaction fence used by callmemory.Service.DeviceRequest. _, err = tx.Exec(f.ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,11))`, f.d.ID) must(t, err) _, err = tx.Exec(f.ctx, `INSERT INTO call_session(device_id,tenant_id,boot_id,hook_event_id,call_generation,binding_generation) VALUES($1,$2,$3,'transfer-race',1,1)`, f.d.ID, f.tenant, uuid.NewString()) must(t, err) ctx, cancel := context.WithTimeout(f.ctx, 150*time.Millisecond) defer cancel() _, err = f.s.BindBLE(ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)}) if err == nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("transfer did not wait for call preparation: %v", err) } must(t, tx.Commit(f.ctx)) if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)}); err == nil { t.Fatal("newly committed call was ignored") } transferUnchanged(t, f, in.ExpectedBindingID, 1) }