408 lines
16 KiB
Go
408 lines
16 KiB
Go
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")
|
||
|
|
}
|
||
|
|
}
|