Files
story/internal/onboarding/account_binding_test.go
T

199 lines
7.6 KiB
Go
Raw Normal View History

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