2026-09-12 08:57:29 +08:00
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"
2026-10-08 10:06:44 +08:00
"data-terminal-cloud/internal/saas"
2026-09-12 08:57:29 +08:00
"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 )
2026-10-08 10:06:44 +08:00
f . s . options . LegacyDeviceRegistration = true // Existing tests exercise old-device migration contracts.
2026-09-12 08:57:29 +08:00
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 )
2026-10-08 10:06:44 +08:00
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 )
}
2026-09-12 08:57:29 +08:00
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 )
2026-10-08 10:06:44 +08:00
profile := ProfileInput { HouseholdName : "测试家庭" , Name : "奶奶" , InitialConsents : saas . InitialConsentSelection { Types : [] string { saas . ConsentTranscriptStorage , saas . ConsentPersonalMemory }, Confirmed : true }}
2026-09-12 08:57:29 +08:00
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" )
}
}