Files

406 lines
16 KiB
Go
Raw Permalink Normal View History

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