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) }