Files
story/internal/onboarding/ble_transfer_test.go
T

301 lines
13 KiB
Go
Raw Normal View History

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(&current, &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)
}