feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func accountOnlyUser(t *testing.T, f *fixture) identity.Principal {
|
||||
t.Helper()
|
||||
p := identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString(), DisplayName: "新账号"}
|
||||
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,account_kind) VALUES($1::uuid,$2,$3,'新账号','customer')`, f.tenant, p.Issuer, p.Subject)
|
||||
must(t, err)
|
||||
return p
|
||||
}
|
||||
|
||||
func accountToken(t *testing.T, f *fixture, p identity.Principal) string {
|
||||
t.Helper()
|
||||
out, err := f.s.CreateBLEToken(f.ctx, p, BLETokenInput{DeviceID: f.d.FactoryID})
|
||||
must(t, err)
|
||||
return out["bind_token"].(string)
|
||||
}
|
||||
|
||||
func TestAccountFirstBindingThenCreateFamilyAndAssign(t *testing.T) {
|
||||
for _, transport := range []string{"mini", "web"} {
|
||||
t.Run(transport, func(t *testing.T) { testAccountFirstBindingThenCreateFamilyAndAssign(t, transport) })
|
||||
}
|
||||
}
|
||||
|
||||
func testAccountFirstBindingThenCreateFamilyAndAssign(t *testing.T, transport string) {
|
||||
f := serverFixture(t)
|
||||
p := accountOnlyUser(t, f)
|
||||
other := accountOnlyUser(t, f)
|
||||
f.web.Hash = ""
|
||||
api := saas.NewService(f.s.pool, audit.NewRepository(), f.s.redis, nil)
|
||||
state, err := f.s.BLEDeviceState(f.ctx, p, BLEDeviceInput{DeviceID: f.d.FactoryID})
|
||||
must(t, err)
|
||||
if state["bound"] != false {
|
||||
t.Fatal(state)
|
||||
}
|
||||
token := accountToken(t, f, p)
|
||||
// Another customer's pending token must not win after physical confirmation.
|
||||
otherToken := accountToken(t, f, other)
|
||||
out, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||||
must(t, err)
|
||||
if len(out) != 6 || out["RTCAppID"] != "test-app" {
|
||||
t.Fatal("existing firmware needs five real provider fields")
|
||||
}
|
||||
f.refresh()
|
||||
var families, subjects int
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT (SELECT count(*) FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id WHERE ua.oidc_subject=$1),(SELECT count(*) FROM subject_profile sp JOIN user_account ua ON ua.id=sp.created_by WHERE ua.oidc_subject=$1)`, p.Subject).Scan(&families, &subjects))
|
||||
if families != 0 || subjects != 0 {
|
||||
t.Fatal("claim fabricated profiles", families, subjects)
|
||||
}
|
||||
devices, err := api.ListDevices(f.ctx, p)
|
||||
must(t, err)
|
||||
if len(devices) != 1 || devices[0].AccountBindingID == "" || devices[0].SubjectID != "" {
|
||||
t.Fatal(devices)
|
||||
}
|
||||
owned := devices[0]
|
||||
_, err = api.GetDeviceTwin(f.ctx, p, f.d.PublicID)
|
||||
must(t, err)
|
||||
if _, err = api.GetDevice(f.ctx, other, f.d.PublicID); err == nil {
|
||||
t.Fatal("other account read device")
|
||||
}
|
||||
if _, err = f.s.BLEDeviceState(f.ctx, other, BLEDeviceInput{DeviceID: f.d.FactoryID}); err == nil {
|
||||
t.Fatal("other account can reconfigure Wi-Fi")
|
||||
}
|
||||
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: otherToken}); err == nil {
|
||||
t.Fatal("competing token stole ownership")
|
||||
}
|
||||
if err = devicebinding.RequireUnboundForReset(f.ctx, f.s.pool, f.d.ID); err == nil {
|
||||
t.Fatal("reset bypassed owner removal")
|
||||
}
|
||||
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||||
must(t, err)
|
||||
if f.registrar.calls.Load() != 1 {
|
||||
t.Fatal("duplicate supplier registration")
|
||||
}
|
||||
chat := callmemory.NewService(f.s.pool, nil)
|
||||
_, err = chat.DirectRequest(f.ctx, deviceauth.Identity{ID: f.d.ID, PublicID: f.d.PublicID, TenantID: f.d.TenantID}, "start", callmemory.DirectInput{SessionID: uuid.NewString()})
|
||||
if err == nil || err.Error() != "DEVICE_UNBOUND" {
|
||||
t.Fatal("chat admitted without subject", err)
|
||||
}
|
||||
h, err := api.CreateHousehold(f.ctx, p, f.tenantPublic, "稍后创建的家庭", uuid.NewString())
|
||||
must(t, err)
|
||||
sp, err := api.CreateSubject(f.ctx, p, h.ID, uuid.NewString(), saas.CreateSubjectInput{PreferredName: "奶奶", Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{}, ConversationPreferences: map[string]any{}})
|
||||
must(t, err)
|
||||
in := AssignDeviceSubjectInput{DeviceID: f.d.PublicID, ElderID: sp.ID, ExpectedAccountBindingID: owned.AccountBindingID}
|
||||
if _, err = f.s.AssignDeviceSubject(f.ctx, other, uuid.NewString(), in); err == nil {
|
||||
t.Fatal("other owner assigned subject")
|
||||
}
|
||||
bad := in
|
||||
bad.ElderID = f.elder
|
||||
if _, err = f.s.AssignDeviceSubject(f.ctx, p, uuid.NewString(), bad); err == nil {
|
||||
t.Fatal("unmanaged family accepted")
|
||||
}
|
||||
session := randomToken(32)
|
||||
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) SELECT $1,$2,id,now()+interval '1 hour' FROM user_account WHERE oidc_issuer=$3 AND oidc_subject=$4`, digest([]byte(session)), digest([]byte(webBLECSRF)), p.Issuer, p.Subject)
|
||||
must(t, err)
|
||||
status, _ := miniRequest(t, f.s, "POST", "/api/v1/mini/device-subject", "", in)
|
||||
if status != 401 {
|
||||
t.Fatal("anonymous assignment", status)
|
||||
}
|
||||
var data map[string]any
|
||||
if transport == "web" {
|
||||
status, data = webBLERequest(t, f.s, "device-subject", session, webBLECSRF, "https://platform.example", in)
|
||||
} else {
|
||||
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/device-subject", "Mini customer."+session, in)
|
||||
}
|
||||
if status != 200 || data["elder_id"] != sp.ID {
|
||||
t.Fatal("mini assignment failed", status, data)
|
||||
}
|
||||
f.refresh()
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
devices, err = api.ListDevices(f.ctx, p)
|
||||
must(t, err)
|
||||
if len(devices) != 1 || devices[0].SubjectID != sp.ID || devices[0].AccountBindingID != "" {
|
||||
t.Fatal("assignment not visible", devices)
|
||||
}
|
||||
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||||
must(t, err)
|
||||
if f.registrar.calls.Load() != 1 {
|
||||
t.Fatal("assignment registered device twice")
|
||||
}
|
||||
_, err = f.s.AssignDeviceSubject(f.ctx, p, uuid.NewString(), in)
|
||||
must(t, err)
|
||||
bad = in
|
||||
bad.ElderID = f.elder
|
||||
if _, err = f.s.AssignDeviceSubject(f.ctx, p, uuid.NewString(), bad); err == nil {
|
||||
t.Fatal("stale assignment changed subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFirstConcurrentClaimReleaseAndReplay(t *testing.T) {
|
||||
f := serverFixture(t)
|
||||
f.web.Hash = ""
|
||||
p := accountOnlyUser(t, f)
|
||||
token := accountToken(t, f, p)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
// Commit concurrently without contacting the supplier; both must be idempotent.
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := f.s.commitBLEBinding(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
must(t, err)
|
||||
}
|
||||
f.refresh()
|
||||
api := saas.NewService(f.s.pool, audit.NewRepository(), f.s.redis, nil)
|
||||
devices, err := api.ListDevices(f.ctx, p)
|
||||
must(t, err)
|
||||
if len(devices) != 1 {
|
||||
t.Fatal(devices)
|
||||
}
|
||||
impact, err := api.GetDeviceBindingImpact(f.ctx, p, f.d.PublicID, "unbind", "")
|
||||
must(t, err)
|
||||
if !impact.Allowed {
|
||||
t.Fatal(impact)
|
||||
}
|
||||
_, err = api.ChangeDeviceBinding(f.ctx, p, f.d.PublicID, uuid.NewString(), saas.ChangeDeviceBindingInput{Action: "unbind", ExpectedBindingID: impact.BindingID, Acknowledged: true, Reason: "移除测试设备"})
|
||||
must(t, err)
|
||||
f.refresh()
|
||||
devices, err = api.ListDevices(f.ctx, p)
|
||||
must(t, err)
|
||||
if len(devices) != 0 {
|
||||
t.Fatal("released device still visible")
|
||||
}
|
||||
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
|
||||
t.Fatal("old token restored removed owner")
|
||||
}
|
||||
state, err := f.s.DeviceBLEState(f.ctx, f.d)
|
||||
must(t, err)
|
||||
if state["bound"] != false {
|
||||
t.Fatal(state)
|
||||
}
|
||||
if err = devicebinding.RequireUnboundForReset(f.ctx, f.s.pool, f.d.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newOwner := accountOnlyUser(t, f)
|
||||
token = accountToken(t, f, newOwner)
|
||||
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||||
must(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user