301 lines
13 KiB
Go
301 lines
13 KiB
Go
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)
|
||
|
|
}
|