223 lines
9.5 KiB
Go
223 lines
9.5 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (f *fixture) deviceCall(path string, body any, id string, secure bool) (int, map[string]any) {
|
||
|
|
f.t.Helper()
|
||
|
|
raw, ok := body.([]byte)
|
||
|
|
if !ok {
|
||
|
|
raw = requestJSON(body)
|
||
|
|
}
|
||
|
|
if id == "" {
|
||
|
|
id = uuid.NewString()
|
||
|
|
}
|
||
|
|
path = "/api/v1/device/" + path
|
||
|
|
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
|
||
|
|
if !secure {
|
||
|
|
r.TLS = nil
|
||
|
|
}
|
||
|
|
nonce, timestamp := randomToken(16), time.Now().UnixMilli()
|
||
|
|
r.Header.Set("Content-Type", "application/json")
|
||
|
|
if !strings.HasSuffix(path, "time-challenges") {
|
||
|
|
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))
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
f.s.RegisterRoutes(mux)
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(w, r)
|
||
|
|
var result map[string]any
|
||
|
|
must(f.t, json.Unmarshal(w.Body.Bytes(), &result))
|
||
|
|
return w.Code, result
|
||
|
|
}
|
||
|
|
func fieldsEqual(t *testing.T, v map[string]any, fields string) {
|
||
|
|
t.Helper()
|
||
|
|
names := strings.Fields(fields)
|
||
|
|
if len(v) != len(names) {
|
||
|
|
t.Fatalf("unexpected fields: %v; expected %s", mapKeys(v), fields)
|
||
|
|
}
|
||
|
|
for _, name := range names {
|
||
|
|
if _, ok := v[name]; !ok {
|
||
|
|
t.Fatalf("missing %s", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func mapKeys(v map[string]any) []string {
|
||
|
|
keys := []string{}
|
||
|
|
for k := range v {
|
||
|
|
keys = append(keys, k)
|
||
|
|
}
|
||
|
|
return keys
|
||
|
|
}
|
||
|
|
func dataOK(t *testing.T, status int, v map[string]any, fields string) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatalf("status=%d code=%v", status, v["code"])
|
||
|
|
}
|
||
|
|
fieldsEqual(t, v, "ok code request_id server_time_ms data")
|
||
|
|
d := v["data"].(map[string]any)
|
||
|
|
fieldsEqual(t, d, fields)
|
||
|
|
if state, ok := d["state"].(map[string]any); ok {
|
||
|
|
fieldsEqual(t, state, "device_id device_revision binding_generation binding_state activation_state state_hash")
|
||
|
|
}
|
||
|
|
return d
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestIntegrationDeviceWireContract(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
// C01 uses plain HTTP without a device Authorization header. Only a signed
|
||
|
|
// time packet is returned; key material and business data are absent.
|
||
|
|
in := TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: f.d.BootID, ClientNonce: randomToken(16)}
|
||
|
|
status, response := f.deviceCall("time-challenges", in, "", false)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
fieldsEqual(t, response, "payload_b64 mac_hex")
|
||
|
|
if !sameSecret(domainMAC(f.d.Key, "TIME1\n"+response["payload_b64"].(string)), response["mac_hex"].(string)) {
|
||
|
|
t.Fatal("time MAC")
|
||
|
|
}
|
||
|
|
decoded, err := base64.RawURLEncoding.DecodeString(response["payload_b64"].(string))
|
||
|
|
must(t, err)
|
||
|
|
var packet map[string]any
|
||
|
|
must(t, json.Unmarshal(decoded, &packet))
|
||
|
|
fieldsEqual(t, packet, "device_id boot_id client_nonce challenge_id server_time_ms expires_at_ms")
|
||
|
|
if packet["client_nonce"] != in.ClientNonce || packet["boot_id"] != in.BootID || packet["device_id"] != in.DeviceID {
|
||
|
|
t.Fatal("time echo")
|
||
|
|
}
|
||
|
|
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: f.d.BootID, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2.0", SDKVersion: "1.0.0"}
|
||
|
|
status, response = f.deviceCall("register", reg, "", true)
|
||
|
|
dataOK(t, status, response, "state contract_version")
|
||
|
|
setup := SetupInput{BootID: f.d.BootID, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
|
||
|
|
// Keep the established fixture setup: a repeated creation with the same
|
||
|
|
// local identity is independently checked in the setup/recovery tests.
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT local_setup_id::text FROM device_setup_session WHERE id=$1::uuid`, f.setup).Scan(&setup.LocalSetupID))
|
||
|
|
status, response = f.deviceCall("setup-sessions", setup, "", true)
|
||
|
|
dataOK(t, status, response, "setup_id expires_at_ms poll_after_ms state")
|
||
|
|
handoff := HandoffInput{SetupID: f.setup, LocalID: setup.LocalSetupID, Challenge: encode64(make([]byte, 32))}
|
||
|
|
status, response = f.deviceCall("handoffs", handoff, "", true)
|
||
|
|
dataOK(t, status, response, "handoff_token expires_at_ms entry_url")
|
||
|
|
f.pending()
|
||
|
|
status, response = f.deviceCall("claim-confirmations", f.confirm, "", true)
|
||
|
|
dataOK(t, status, response, "claim_id state already_applied")
|
||
|
|
f.refresh()
|
||
|
|
poll := PollInput{SetupID: f.setup, BootID: f.d.BootID}
|
||
|
|
status, response = f.deviceCall("poll", poll, "", true)
|
||
|
|
data := dataOK(t, status, response, "state command poll_after_ms")
|
||
|
|
st := data["state"].(map[string]any)
|
||
|
|
ack := StateAckInput{Revision: int64(st["device_revision"].(float64)), Generation: f.d.Generation, Hash: st["state_hash"].(string), LocalGeneration: 1}
|
||
|
|
ackID := uuid.NewString()
|
||
|
|
status, response = f.deviceCall("state-acks", ack, ackID, true)
|
||
|
|
dataOK(t, status, response, "accepted_revision setup_can_close")
|
||
|
|
f.s.now = func() time.Time { return time.Now().Add(31 * time.Second) }
|
||
|
|
status, response = f.deviceCall("state-acks", ack, ackID, true)
|
||
|
|
if !dataOK(t, status, response, "accepted_revision setup_can_close")["setup_can_close"].(bool) {
|
||
|
|
t.Fatal("ACK replay kept stale close result")
|
||
|
|
}
|
||
|
|
f.s.now = time.Now
|
||
|
|
j := f.job()
|
||
|
|
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||
|
|
status, response = f.deviceCall("activation-attempts", attempt, "", true)
|
||
|
|
dataOK(t, status, response, "allowed attempt_id permit_expires_at_ms state")
|
||
|
|
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
|
||
|
|
status, response = f.deviceCall("activation-results", result, "", true)
|
||
|
|
dataOK(t, status, response, "activation_id state reconcile_required")
|
||
|
|
activationAck := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 2}
|
||
|
|
status, response = f.deviceCall("activation-acks", activationAck, "", true)
|
||
|
|
dataOK(t, status, response, "accepted state")
|
||
|
|
attempt.AttemptID = uuid.NewString()
|
||
|
|
status, response = f.deviceCall("activation-attempts", attempt, "", true)
|
||
|
|
dataOK(t, status, response, "allowed attempt_id permit_expires_at_ms state")
|
||
|
|
result.AttemptID = attempt.AttemptID
|
||
|
|
result.Stage = "CONVERSATION_TEST"
|
||
|
|
status, response = f.deviceCall("activation-results", result, "", true)
|
||
|
|
data = dataOK(t, status, response, "activation_id state reconcile_required")
|
||
|
|
st = data["state"].(map[string]any)
|
||
|
|
if st["activation_state"] != "READY" {
|
||
|
|
t.Fatal("direct SDK first call not READY")
|
||
|
|
}
|
||
|
|
closeInput := CloseInput{SetupID: f.setup, Reason: "CANCELLED", Revision: int64(st["device_revision"].(float64))}
|
||
|
|
status, response = f.deviceCall("setup-close", closeInput, "", true)
|
||
|
|
dataOK(t, status, response, "close_allowed state")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestIntegrationDeviceRejectsRemovedAndMissingFields(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
for _, body := range []map[string]any{
|
||
|
|
{"setup_id": f.setup, "boot_id": f.d.BootID},
|
||
|
|
{"setup_id": f.setup, "boot_id": f.d.BootID, "known_revision": nil},
|
||
|
|
{"setup_id": f.setup, "boot_id": f.d.BootID, "known_revision": 2147483648},
|
||
|
|
} {
|
||
|
|
status, r := f.deviceCall("poll", body, "", true)
|
||
|
|
if status != 400 {
|
||
|
|
t.Fatal("invalid required counter", status)
|
||
|
|
}
|
||
|
|
fieldsEqual(t, r, "ok code request_id message")
|
||
|
|
}
|
||
|
|
for path, body := range map[string]map[string]any{
|
||
|
|
"activation-attempts": {"activation_id": uuid.NewString(), "config_version": 1, "attempt_id": uuid.NewString(), "stage": "SDK_REGISTER"},
|
||
|
|
"activation-results": {"activation_id": uuid.NewString(), "attempt_id": uuid.NewString(), "stage": "SDK_REGISTER", "sdk_result": 0, "outcome": "SUCCESS_OBSERVED", "evidence": map[string]any{}},
|
||
|
|
"activation-acks": {"activation_id": uuid.NewString(), "config_version": 1, "local_generation": 1, "config_digest": strings.Repeat("a", 64)},
|
||
|
|
} {
|
||
|
|
status, _ := f.deviceCall(path, body, "", true)
|
||
|
|
if status != 400 {
|
||
|
|
t.Fatal(path, status)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
status, r := f.deviceCall("poll", PollInput{SetupID: f.setup, BootID: f.d.BootID}, "", false)
|
||
|
|
if status != 400 || r["code"] != "HTTPS_REQUIRED" {
|
||
|
|
t.Fatal("business HTTP accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestIntegrationRebootInvalidatesUncertainPermit(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
f.bound()
|
||
|
|
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||
|
|
f.refresh()
|
||
|
|
j := f.job()
|
||
|
|
input := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||
|
|
id := uuid.NewString()
|
||
|
|
_, err := f.s.Attempt(f.ctx, f.d, id, requestJSON(input), input)
|
||
|
|
must(t, err)
|
||
|
|
boot := uuid.NewString()
|
||
|
|
challenge, err := f.s.TimeChallenge(f.ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: boot, ClientNonce: randomToken(16)})
|
||
|
|
must(t, err)
|
||
|
|
raw, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
|
||
|
|
must(t, err)
|
||
|
|
var packet map[string]any
|
||
|
|
must(t, json.Unmarshal(raw, &packet))
|
||
|
|
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2", SDKVersion: "1.0"}
|
||
|
|
_, err = f.s.Register(f.ctx, f.d, uuid.NewString(), requestJSON(reg), reg)
|
||
|
|
must(t, err)
|
||
|
|
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||
|
|
must(t, err)
|
||
|
|
_, err = f.s.Attempt(f.ctx, f.d, id, requestJSON(input), input)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("reboot replayed an uncertain SDK permit")
|
||
|
|
}
|
||
|
|
input.AttemptID = uuid.NewString()
|
||
|
|
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(input), input)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("reboot issued another SDK permit")
|
||
|
|
}
|
||
|
|
if f.job().Status != "RECONCILE_REQUIRED" {
|
||
|
|
t.Fatal("reboot lost uncertainty")
|
||
|
|
}
|
||
|
|
}
|