Files
story/internal/onboarding/device_contract_test.go
T

223 lines
9.5 KiB
Go
Raw Normal View History

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