Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

406 lines
16 KiB
Go

package onboarding
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/saas"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
type testRegistrar struct {
calls atomic.Int32
err error
started chan struct{}
release chan struct{}
}
func (r *testRegistrar) Register(ctx context.Context, in hardwareprovision.RegisterInput) (hardwareprovision.RegisterResult, error) {
r.calls.Add(1)
if r.started != nil {
close(r.started)
select {
case <-r.release:
case <-ctx.Done():
return hardwareprovision.RegisterResult{}, ctx.Err()
}
}
return hardwareprovision.RegisterResult{DeviceSecret: "fixture-device-secret-at-least-32-bytes", RTCAppID: "test-app"}, r.err
}
type fixture struct {
t *testing.T
s *Service
ctx context.Context
tenant, tenantPublic, operatorID string
p, user identity.Principal
d Device
web WebSession
setup, elder, claim string
confirm ConfirmationInput
registrar *testRegistrar
}
func newFixture(t *testing.T) *fixture {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
must(t, err)
t.Cleanup(pool.Close)
addr := os.Getenv("TEST_REDIS_ADDR")
if addr == "" {
addr = "127.0.0.1:56379"
}
cache := redis.NewClient(&redis.Options{Addr: addr})
must(t, cache.Ping(ctx).Err())
t.Cleanup(func() { _ = cache.Close() })
f := &fixture{t: t, ctx: ctx, tenant: uuid.NewString(), tenantPublic: uuid.NewString(), operatorID: uuid.NewString(), p: identity.Principal{Issuer: "urn:onboarding:test", Subject: uuid.NewString()}, user: identity.Principal{Issuer: "urn:onboarding:test", Subject: uuid.NewString(), DisplayName: "测试用户1234"}, registrar: &testRegistrar{}}
f.s, err = NewService(pool, cache, Options{Enabled: true, MasterKey: strings.Repeat("15", 32), ProviderMasterKey: strings.Repeat("25", 32), EntryURL: "https://platform.example/onboarding/entry", SelfSignupTenantIDs: []string{f.tenantPublic}, ActivationEnabled: true, Registrar: f.registrar, InstanceID: "test-instance", ProductKey: "test-product", ProductSecret: "fixture-product-secret-at-least-32-bytes", BotID: "test-bot", Transport: "high_quality", MinimumSDKVersion: "1.0.0"})
must(t, err)
_, err = pool.Exec(ctx, `INSERT INTO tenant(id,public_id,name) VALUES($1::uuid,$2::uuid,'onboarding integration')`, f.tenant, f.tenantPublic)
f.s.options.LegacyDeviceRegistration = true // Existing tests exercise old-device migration contracts.
must(t, err)
_, err = pool.Exec(ctx, `INSERT INTO user_account(id,tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2::uuid,$3,$4,'生产管理员','ops_admin','operator')`, f.operatorID, f.tenant, f.p.Issuer, f.p.Subject)
must(t, err)
mac := strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", ""))[:12]
in := InventoryInput{FactoryMAC: mac, Model: "KP01", HardwareRevision: "A1", BatchNo: "test"}
raw := requestJSON(in)
key := uuid.NewString()
inv, err := f.s.CreateInventory(ctx, f.p, f.tenantPublic, uuid.NewString(), key, raw, in)
must(t, err)
again, err := f.s.CreateInventory(ctx, f.p, f.tenantPublic, uuid.NewString(), key, raw, in)
must(t, err)
if inv["platform_device_id"] != again["platform_device_id"] {
t.Fatal("inventory identity changed")
}
public := inv["platform_device_id"].(string)
job := inv["enrollment_job_id"].(string)
if inv["released"] != true {
t.Fatal("new inventory did not become immediately claimable")
}
var lifecycle, keyStatus, jobStatus string
must(t, f.s.pool.QueryRow(ctx, `SELECT d.activation_state,k.status,ej.status FROM device d JOIN device_onboarding_key k ON k.device_id=d.id AND k.key_version=1 JOIN device_enrollment_job ej ON ej.device_id=d.id AND ej.id=$2::uuid WHERE d.public_id=$1::uuid`, public, job).Scan(&lifecycle, &keyStatus, &jobStatus))
if lifecycle != "CLAIMABLE" || keyStatus != "ACTIVE" || jobStatus != "RELEASED" {
t.Fatalf("direct inventory state lifecycle=%s key=%s job=%s", lifecycle, keyStatus, jobStatus)
}
f.d, err = f.s.findFactoryDevice(ctx, "KP01-"+mac, 1)
must(t, err)
boot := uuid.NewString()
challenge, err := f.s.TimeChallenge(ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: boot, ClientNonce: randomToken(16)})
must(t, err)
decoded, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
must(t, err)
var packet map[string]any
must(t, json.Unmarshal(decoded, &packet))
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2.0", SDKVersion: "1.0.0"}
requestID := uuid.NewString()
_, err = f.s.Register(ctx, f.d, requestID, requestJSON(reg), reg)
must(t, err)
f.refresh()
_, err = f.s.Register(ctx, f.d, requestID, requestJSON(reg), reg)
must(t, err)
setupIn := SetupInput{BootID: boot, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
setup, err := f.s.Setup(ctx, f.d, uuid.NewString(), requestJSON(setupIn), setupIn)
must(t, err)
f.setup = setup["setup_id"].(string)
verifier := randomToken(32)
sum := sha256.Sum256([]byte(verifier))
handoffIn := HandoffInput{SetupID: f.setup, LocalID: setupIn.LocalSetupID, Challenge: encode64(sum[:])}
handoff, err := f.s.Handoff(ctx, f.d, uuid.NewString(), requestJSON(handoffIn), handoffIn)
must(t, err)
credentials, err := f.s.Exchange(ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
must(t, err)
_, err = f.s.Exchange(ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
if err == nil {
t.Fatal("handoff redeemed twice")
}
ws, err := f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
must(t, err)
credentials, err = f.s.AuthLink(ctx, ws, f.user)
must(t, err)
f.web, err = f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
must(t, err)
profile := ProfileInput{HouseholdName: "测试家庭", Name: "奶奶", InitialConsents: saas.InitialConsentSelection{Types: []string{saas.ConsentTranscriptStorage, saas.ConsentPersonalMemory}, Confirmed: true}}
result, err := f.s.CreateProfile(ctx, f.web, uuid.NewString(), requestJSON(profile), profile)
must(t, err)
f.elder = result["elder_id"].(string)
return f
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func (f *fixture) refresh() {
f.t.Helper()
var err error
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(f.t, err)
if f.web.Hash != "" {
f.web, err = f.s.webSession(f.ctx, f.s.pool, f.web.Hash)
must(f.t, err)
}
}
func (f *fixture) pending() {
f.t.Helper()
in := ClaimInput{ElderID: f.elder, Generation: f.d.Generation}
result, err := f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
must(f.t, err)
f.claim = result["claim_id"].(string)
poll, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
must(f.t, err)
payload := poll["command"].(map[string]any)["payload"].(map[string]any)
f.confirm = ConfirmationInput{ClaimID: f.claim, Challenge: payload["challenge"].(string), Digest: payload["binding_digest"].(string), ExpectedGeneration: f.d.Generation, PressEventID: uuid.NewString()}
}
func (f *fixture) bound() {
f.t.Helper()
f.pending()
_, err := f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(f.confirm), f.confirm)
must(f.t, err)
f.refresh()
}
func (f *fixture) event() outbox.Event {
f.t.Helper()
var e outbox.Event
err := f.s.pool.QueryRow(f.ctx, `SELECT id::text,payload FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type=$2 ORDER BY created_at DESC LIMIT 1`, f.d.ID, ActivationEvent).Scan(&e.ID, &e.Payload)
must(f.t, err)
return e
}
func TestIntegrationPhysicalConfirmationAndReplay(t *testing.T) {
f := newFixture(t)
f.pending()
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid`, f.d.ID).Scan(&count))
if count != 0 {
t.Fatal("claim established ownership before physical confirmation")
}
id := uuid.NewString()
_, err := f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
must(t, err)
f.refresh()
if f.d.Generation != 1 {
t.Fatal("wrong binding generation")
}
_, err = f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
must(t, err)
status, err := f.s.WebStatus(f.ctx, f.web)
must(t, err)
if status["binding_id"] == nil {
t.Fatal("binding result not recoverable")
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET status='removed' WHERE user_id=$1::uuid`, f.web.UserID)
must(t, err)
_, err = f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
if !errors.Is(err, errAccess) {
t.Fatalf("replay bypassed revoked permission: %v", err)
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
must(t, err)
current, err := readDevice(f.ctx, f.s.pool, f.d.ID, false)
must(t, err)
if current.Generation != 2 {
t.Fatal("unbind did not increment generation")
}
_, err = f.s.Confirm(f.ctx, current, id, requestJSON(f.confirm), f.confirm)
if err == nil {
t.Fatal("old confirmation revived unbound owner")
}
}
func TestIntegrationConcurrentClaimsAndCancel(t *testing.T) {
f := newFixture(t)
in := ClaimInput{ElderID: f.elder, Generation: 0}
var winners atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
if err == nil {
winners.Add(1)
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatalf("claim winners=%d", winners.Load())
}
var id string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT id::text FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING'`, f.d.ID).Scan(&id))
cancel := CancelInput{ClaimID: id, Generation: 0}
_, err := f.s.Cancel(f.ctx, f.web, uuid.NewString(), requestJSON(cancel), cancel)
must(t, err)
var challenge, hash string
var encrypted []byte
must(t, f.s.pool.QueryRow(f.ctx, `SELECT challenge_ciphertext,binding_digest FROM device_binding_claim WHERE id=$1::uuid`, id).Scan(&encrypted, &hash))
plain, err := f.s.crypt.open(encrypted, "claim:"+id)
must(t, err)
challenge = string(plain)
confirm := ConfirmationInput{ClaimID: id, Challenge: challenge, Digest: hash, ExpectedGeneration: 0, PressEventID: uuid.NewString()}
_, err = f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(confirm), confirm)
if err == nil {
t.Fatal("cancelled claim confirmed")
}
}
func (f *fixture) job() activationJob {
f.t.Helper()
tx, err := f.s.pool.Begin(f.ctx)
must(f.t, err)
defer tx.Rollback(f.ctx)
j, err := readJob(f.ctx, tx, f.d.ID)
must(f.t, err)
return j
}
func (f *fixture) registerSDK() activationJob {
f.t.Helper()
must(f.t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
j := f.job()
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
_, err := f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
must(f.t, err)
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
_, err = f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
must(f.t, err)
return f.job()
}
func TestIntegrationDeviceUnknownDoesNotRegisterAgain(t *testing.T) {
f := newFixture(t)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
j := f.job()
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
permit, err := f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
must(t, err)
if !permit["allowed"].(bool) || time.UnixMilli(permit["permit_expires_at_ms"].(int64)).Sub(time.Now()) > 30*time.Second {
t.Fatal("permit contract")
}
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", SDKResult: -1, Outcome: "UNKNOWN"}
response, err := f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
must(t, err)
if response["reconcile_required"] != true {
t.Fatal("unknown must require reconciliation")
}
attempt.AttemptID = uuid.NewString()
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
if err == nil {
t.Fatal("unknown issued another execution")
}
must(t, f.s.ProcessActivation(f.ctx, f.event()))
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 0 {
t.Fatal("SaaS invoked supplier registration")
}
var bound bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, f.d.ID).Scan(&bound))
if !bound {
t.Fatal("unknown removed binding")
}
}
func TestIntegrationDeviceConfigAndRegistration(t *testing.T) {
f := newFixture(t)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
response, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
must(t, err)
payload := response["command"].(map[string]any)["payload"].(map[string]any)
if len(payload) != 2 {
t.Fatal("extra activation command fields")
}
raw, err := base64.RawURLEncoding.DecodeString(payload["config_b64"].(string))
must(t, err)
var config map[string]any
must(t, json.Unmarshal(raw, &config))
if len(config) != 8 || config["contract_version"] != "ONBOARDING-1.1" {
t.Fatal("wrong activation config")
}
iot := config["iot"].(map[string]any)
if len(iot) != 4 || iot["product_secret"] != f.s.options.ProductSecret || iot["device_name"] != f.d.FactoryID {
t.Fatal("wrong SDK registration config")
}
if !sameSecret(domainMAC(f.d.Key, "CFG1\n"+payload["config_b64"].(string)), payload["config_mac"].(string)) {
t.Fatal("config MAC")
}
j := f.job()
ack := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 1}
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
if err == nil {
t.Fatal("ACK before registration was accepted")
}
f.registerSDK()
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
if f.job().Status != "REGISTERED_OBSERVED" {
t.Fatal("registration falsely implies first call")
}
if f.registrar.calls.Load() != 0 {
t.Fatal("server performed SDK registration")
}
}
func TestIntegrationDEVREQReplayAndTLS(t *testing.T) {
f := newFixture(t)
in := PollInput{SetupID: f.setup, BootID: f.d.BootID}
raw := requestJSON(in)
path := "/api/v1/device/poll"
id := uuid.NewString()
nonce := randomToken(16)
timestamp := time.Now().UnixMilli()
request := func() *http.Request {
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Device-ID", f.d.FactoryID)
r.Header.Set("X-Key-Version", "1")
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
r.Header.Set("X-Nonce", nonce)
r.Header.Set("X-Request-ID", id)
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, id, raw))
return r
}
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, request())
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = httptest.NewRecorder()
mux.ServeHTTP(w, request())
if w.Code != 401 {
t.Fatal("nonce replay was accepted", w.Code)
}
r := request()
r.TLS = nil
r.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != 400 {
t.Fatal("untrusted forwarded HTTPS accepted")
}
}