package onboarding import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "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" "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) 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) receiver, err := rsa.GenerateKey(rand.Reader, 2048) must(t, err) pub, err := x509.MarshalPKIXPublicKey(&receiver.PublicKey) must(t, err) _, err = f.s.DeliverCredential(ctx, f.p, f.tenantPublic, public, uuid.NewString(), DeliveryInput{JobID: job, ReceiverKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}))}) must(t, err) _, err = f.s.ReleaseInventory(ctx, f.p, f.tenantPublic, public, uuid.NewString(), ReleaseInput{JobID: job, Evidence: "fixture-burn-readback", ExpectedVersion: 1}) must(t, err) 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: "奶奶"} 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") } }