ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
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)
|
|
}
|