199 lines
7.6 KiB
Go
199 lines
7.6 KiB
Go
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)
|
||
|
|
}
|