package main import ( "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "data-terminal-cloud/internal/deviceauth" ) func TestSimulatorRequestSignsBodyAndReturnsEnvelope(t *testing.T) { secret := []byte("simulator-request-secret-at-least-32-bytes") server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { body, _ := io.ReadAll(request.Body) digest := sha256.Sum256(body) if request.Header.Get(deviceauth.HeaderDeviceID) != "device-1" || request.Header.Get(deviceauth.HeaderContentSHA256) != hex.EncodeToString(digest[:]) || request.Header.Get(deviceauth.HeaderSignature) == "" { t.Errorf("invalid signed headers: %v", request.Header) } if request.Header.Get("Idempotency-Key") != "idem-1" { t.Errorf("idempotency key=%q", request.Header.Get("Idempotency-Key")) } writer.Header().Set("ETag", `"3"`) writer.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(writer, `{"data":{"status":"ok"}}`) })) defer server.Close() sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: secret, client: server.Client()} result, err := sim.request(context.Background(), http.MethodPost, "/device/v2/test", []byte(`{"value":1}`), "idem-1") if err != nil || result.Status != http.StatusOK || result.Header.Get("ETag") != `"3"` { t.Fatalf("result=%+v err=%v", result, err) } var payload struct { Status string `json:"status"` } if err := decodeData(result.Body, &payload); err != nil || payload.Status != "ok" { t.Fatalf("payload=%+v err=%v", payload, err) } } func TestSimulatorHelpersAndValidation(t *testing.T) { scenarios := parseScenarios(" Bootstrap,TELEMETRY, bootstrap ") if !scenarios["bootstrap"] || !scenarios["telemetry"] || len(scenarios) != 2 { t.Fatalf("scenarios=%v", scenarios) } wav := syntheticWAV(100) if string(wav[:4]) != "RIFF" || string(wav[8:12]) != "WAVE" || binary.LittleEndian.Uint32(wav[40:44]) != uint32(len(wav)-44) { t.Fatalf("invalid WAV header: size=%d", len(wav)) } if len(jsonBody(map[string]int{"value": 1})) == 0 { t.Fatal("json body is empty") } if value, err := newNonce(); err != nil || len(value) != 32 { t.Fatalf("nonce=%q err=%v", value, err) } t.Setenv("SIM_DEVICE_SECRET", "short") if err := run(context.Background()); err == nil || !strings.Contains(err.Error(), "at least 32") { t.Fatalf("short secret error=%v", err) } } func TestCreatedConversationIgnoresEngineMode(t *testing.T) { decode := func(mode string) createdConversation { var created createdConversation body := []byte(`{"data":{"conversation_id":"conversation-1","rtc":{"room_id":"room-1","user_id":"device-1","expires_at":"2026-08-23T12:05:00Z"},"session":{"engine_mode":"` + mode + `"}}}`) if err := decodeData(body, &created); err != nil { t.Fatalf("decode mode %q: %v", mode, err) } return created } for _, mode := range []string{"s2s_pure", "s2s_hybrid", "cascade", "future_mode"} { created := decode(mode) if created.ID != "conversation-1" || created.RTC.RoomID != "room-1" || created.RTC.UserID != "device-1" { t.Fatalf("mode %q changed terminal media inputs: %#v", mode, created) } } } func TestSimulatorWaitForConversationState(t *testing.T) { for _, test := range []struct { name, response, wantError string }{ {name: "active", response: `{"data":{"status":"ACTIVE","action":"continue"}}`}, {name: "failed", response: `{"data":{"status":"FAILED","action":"leave_room","failure_code":"provider_start_rejected"}}`, wantError: "provider_start_rejected"}, } { t.Run(test.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet || request.URL.Path != "/device/v2/conversations/conversation-1" { t.Errorf("unexpected request %s %s", request.Method, request.URL.Path) } writer.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(writer, test.response) })) defer server.Close() sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()} err := sim.waitForConversationActive(context.Background(), "conversation-1", time.Second) if test.wantError == "" && err != nil { t.Fatalf("wait failed: %v", err) } if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) { t.Fatalf("error=%v want %q", err, test.wantError) } }) } } func TestSimulatorHTTPAndMediaFailures(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { writer.WriteHeader(http.StatusConflict) _, _ = io.WriteString(writer, `{"error":{"code":"CONFLICT"}}`) })) defer server.Close() sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()} if _, err := sim.request(context.Background(), http.MethodGet, "/conflict", nil, ""); err == nil || !strings.Contains(err.Error(), "HTTP 409") { t.Fatalf("conflict error=%v", err) } t.Setenv("SIM_MEDIA_DURATION_MS", "0") if err := sim.media(context.Background(), "conversation-1"); err == nil || !strings.Contains(err.Error(), "between 1 and 600000") { t.Fatalf("media validation error=%v", err) } var target map[string]any if err := decodeData([]byte(`not-json`), &target); err == nil { t.Fatal("invalid envelope accepted") } encoded, _ := json.Marshal(map[string]any{"data": "not-an-object"}) if err := decodeData(encoded, &target); err == nil { t.Fatal("invalid data payload accepted") } } func TestSimulatorMediaCompletesVerificationAndDeleteReceipt(t *testing.T) { deleteAcknowledged := false var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") switch { case request.Method == http.MethodPost && request.URL.Path == "/device/v2/conversations/conversation-1/upload-batches": _, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"UPLOADING","segments":[{"id":"segment-1","upload_url":"`+server.URL+`/upload","content_type":"audio/wav"}]}}`) case request.Method == http.MethodPut && request.URL.Path == "/upload": writer.WriteHeader(http.StatusOK) case request.Method == http.MethodPost && request.URL.Path == "/device/v2/upload-batches/batch-1/complete": _, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"VERIFYING","segments":[]}}`) case request.Method == http.MethodGet && request.URL.Path == "/device/v2/upload-batches/batch-1": deleteAfter := time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) _, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"COMPLETED","segments":[{"id":"segment-1","status":"VERIFIED","local_delete_after":"`+deleteAfter+`"}]}}`) case request.Method == http.MethodPost && request.URL.Path == "/device/v2/media-segments/segment-1/local-delete-ack": if request.Header.Get("Idempotency-Key") == "" { t.Error("local delete receipt has no idempotency key") } deleteAcknowledged = true _, _ = io.WriteString(writer, `{"data":{"id":"segment-1","status":"VERIFIED","local_delete_status":"DELETED"}}`) default: http.NotFound(writer, request) } })) defer server.Close() t.Setenv("SIM_MEDIA_VERIFY_TIMEOUT", "2s") t.Setenv("SIM_MEDIA_DELETE_WAIT_MAX", "2s") sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()} if err := sim.media(context.Background(), "conversation-1"); err != nil { t.Fatalf("media flow error=%v", err) } if !deleteAcknowledged { t.Fatal("local delete receipt was not sent") } } func TestSimulatorFirmwareCommandSuccess(t *testing.T) { content := []byte("signed simulated firmware image") payload := signedSimulatorFirmwarePayload(t, content, "2.3.1") acks := []map[string]any{} var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") switch { case request.Method == http.MethodGet && request.URL.Path == "/device/v2/firmware-releases/release-1/download": _, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`) case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin": writer.Header().Set("Content-Type", "application/octet-stream") _, _ = writer.Write(content) case request.Method == http.MethodPost && request.URL.Path == "/device/v2/commands/command-1/ack": var body map[string]any if err := json.NewDecoder(request.Body).Decode(&body); err != nil { t.Errorf("decode ack: %v", err) } acks = append(acks, body) _, _ = io.WriteString(writer, `{"data":{"id":"command-1"}}`) default: http.NotFound(writer, request) } })) defer server.Close() sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()} command := simulatedDeviceCommand{ID: "command-1", Type: "firmware_update", Payload: payload} outcome, err := sim.executeFirmwareCommand(context.Background(), command) if err != nil || outcome != firmwareCompleted { t.Fatalf("outcome=%q err=%v", outcome, err) } if len(acks) != 4 || acks[0]["status"] != "ACKNOWLEDGED" || acks[1]["status"] != "ACKNOWLEDGED" || acks[2]["status"] != "ACKNOWLEDGED" || acks[3]["status"] != "SUCCEEDED" { t.Fatalf("acks=%+v", acks) } firstResult := acks[0]["result_summary"].(map[string]any) downloadResult := acks[1]["result_summary"].(map[string]any) installResult := acks[2]["result_summary"].(map[string]any) finalResult := acks[3]["result_summary"].(map[string]any) if firstResult["phase"] != "downloading" || downloadResult["result_code"] != "ARTIFACT_VERIFIED" || downloadResult["verified_signing_key_id"] != "sim-test-key" || installResult["phase"] != "installing" || finalResult["installed_version"] != "2.3.1" || finalResult["verified_sha256"] != payload["sha256"] { t.Fatalf("firmware results=%+v", acks) } } func TestSimulatorFirmwareCommandFaultsAndInterruptions(t *testing.T) { content := []byte("simulated firmware fault fixture") tests := []struct { fault string wantOutcome firmwareExecutionOutcome wantStatuses []string wantCode string }{ {fault: "download_failed", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "DOWNLOAD_FAILED"}, {fault: "digest_mismatch", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "DIGEST_MISMATCH"}, {fault: "signature_mismatch", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "SIGNATURE_MISMATCH"}, {fault: "install_failed", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED", "ACKNOWLEDGED", "FAILED"}, wantCode: "INSTALL_FAILED"}, {fault: "interrupt_after_download", wantOutcome: firmwareInterrupted, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED"}, wantCode: "ARTIFACT_VERIFIED"}, {fault: "interrupt_after_install", wantOutcome: firmwareInterrupted, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED", "ACKNOWLEDGED"}, wantCode: "INSTALL_STARTED"}, } for _, test := range tests { t.Run(test.fault, func(t *testing.T) { t.Setenv("SIM_FIRMWARE_FAULT", test.fault) payload := signedSimulatorFirmwarePayload(t, content, "2.3.1") acks := []map[string]any{} var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") switch { case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/download"): _, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`) case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin": _, _ = writer.Write(content) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/ack"): var body map[string]any _ = json.NewDecoder(request.Body).Decode(&body) acks = append(acks, body) _, _ = io.WriteString(writer, `{"data":{"id":"command-1"}}`) default: http.NotFound(writer, request) } })) defer server.Close() sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()} command := simulatedDeviceCommand{ID: "command-1", Type: "firmware_update", Payload: payload} outcome, err := sim.executeFirmwareCommand(context.Background(), command) if err != nil || outcome != test.wantOutcome { t.Fatalf("outcome=%q err=%v", outcome, err) } statuses := make([]string, 0, len(acks)) for _, ack := range acks { statuses = append(statuses, ack["status"].(string)) } if strings.Join(statuses, ",") != strings.Join(test.wantStatuses, ",") { t.Fatalf("statuses=%v want=%v", statuses, test.wantStatuses) } lastResult := acks[len(acks)-1]["result_summary"].(map[string]any) if lastResult["result_code"] != test.wantCode { t.Fatalf("last result=%+v", lastResult) } }) } } func TestSimulatorFirmwareManifestAndConfigurationValidation(t *testing.T) { digest := strings.Repeat("a", 64) valid := map[string]any{"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download"} if _, err := parseSimulatedFirmwareManifest(valid); err != nil { t.Fatalf("valid manifest error=%v", err) } for name, payload := range map[string]map[string]any{ "external path": {"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "https://example.invalid/firmware"}, "traversal": {"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/../download"}, "uppercase": {"release_id": "release-1", "version": "2.3.1", "sha256": strings.Repeat("A", 64), "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download"}, } { t.Run(name, func(t *testing.T) { if _, err := parseSimulatedFirmwareManifest(payload); err == nil { t.Fatalf("invalid manifest accepted: %+v", payload) } }) } t.Setenv("SIM_FIRMWARE_FAULT", "unknown") if _, err := simulatedFirmwareFault(); err == nil { t.Fatal("unknown firmware fault was accepted") } t.Setenv("SIM_FIRMWARE_STEP_DELAY", "31s") if _, err := simulatorOptionalDuration("SIM_FIRMWARE_STEP_DELAY", 30*time.Second); err == nil { t.Fatal("excessive firmware step delay was accepted") } t.Setenv("SIM_FIRMWARE_TRUSTED_KEYS", `{}`) if _, err := simulatedFirmwareTrustedKey("sim-test-key"); err == nil { t.Fatal("unprovisioned firmware signing key was accepted") } t.Setenv("SIM_FIRMWARE_AB_UPDATE", "yes") if _, err := simulatedABFirmwareEnabled(); err == nil { t.Fatal("invalid A/B firmware flag was accepted") } t.Setenv("SIM_FIRMWARE_AB_UPDATE", "true") t.Setenv("SIM_FIRMWARE_STATE_FILE", "relative-state.json") if _, err := simulatedABFirmwareEnabled(); err == nil { t.Fatal("relative A/B firmware state path was accepted") } } func TestSimulatorABFirmwareBootConfirmationAndRollback(t *testing.T) { for _, test := range []struct { name, stageFault, bootFault, wantOutcome, wantPhase, wantSlot, wantCode string retryAfterBoot bool wantAckCount int }{ {name: "healthy boot", stageFault: "none", bootFault: "none", wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 7}, {name: "failed boot rolls back", stageFault: "none", bootFault: "boot_health_failed", wantOutcome: string(firmwareFailed), wantPhase: firmwareBootRolledBack, wantSlot: "A", wantCode: "BOOT_HEALTH_FAILED_ROLLED_BACK", wantAckCount: 7}, {name: "power loss after staging resumes", stageFault: "power_loss_after_stage", bootFault: "none", wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 7}, {name: "power loss during boot resumes", stageFault: "none", bootFault: "power_loss_during_boot", retryAfterBoot: true, wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 8}, } { t.Run(test.name, func(t *testing.T) { content := []byte("simulated A/B firmware image") payload := signedSimulatorFirmwarePayload(t, content, "3.1.0") stateFile := filepath.Join(t.TempDir(), "firmware-state.json") t.Setenv("SIM_FIRMWARE_AB_UPDATE", "true") t.Setenv("SIM_FIRMWARE_STATE_FILE", stateFile) t.Setenv("SIM_FIRMWARE_BOOT_HEALTH_WINDOW", "30s") t.Setenv("SIM_FIRMWARE_CURRENT_VERSION", "0.2.0-sim") t.Setenv("SIM_FIRMWARE_FAULT", test.stageFault) acks := []map[string]any{} var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") switch { case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/download"): _, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`) case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin": _, _ = writer.Write(content) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/ack"): var body map[string]any _ = json.NewDecoder(request.Body).Decode(&body) acks = append(acks, body) _, _ = io.WriteString(writer, `{"data":{"id":"command-ab"}}`) default: http.NotFound(writer, request) } })) defer server.Close() sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()} command := simulatedDeviceCommand{ID: "command-ab", Type: "firmware_update", Payload: payload} outcome, err := sim.executeFirmwareCommand(context.Background(), command) if err != nil { t.Fatalf("staging outcome=%q err=%v", outcome, err) } if test.stageFault == "power_loss_after_stage" { if outcome != firmwareInterrupted { t.Fatalf("power loss staging outcome=%q", outcome) } staged, stateErr := loadSimulatedFirmwareBootState() if stateErr != nil || staged.Phase != firmwareBootStaged { t.Fatalf("power loss staged state=%+v err=%v", staged, stateErr) } t.Setenv("SIM_FIRMWARE_FAULT", "none") outcome, err = sim.executeFirmwareCommand(context.Background(), command) } if err != nil || outcome != firmwareRebootRequired { t.Fatalf("reboot arming outcome=%q err=%v", outcome, err) } staged, err := loadSimulatedFirmwareBootState() if err != nil || staged.Phase != firmwareBootRebootPending || staged.ActiveSlot != "A" || staged.TargetSlot != "B" || staged.BootAttemptID == "" { t.Fatalf("staged state=%+v err=%v", staged, err) } t.Setenv("SIM_FIRMWARE_FAULT", test.bootFault) outcome, err = sim.executeFirmwareCommand(context.Background(), command) if test.retryAfterBoot { if err != nil || outcome != firmwareInterrupted { t.Fatalf("boot power loss outcome=%q err=%v", outcome, err) } t.Setenv("SIM_FIRMWARE_FAULT", "none") outcome, err = sim.executeFirmwareCommand(context.Background(), command) } if err != nil || string(outcome) != test.wantOutcome { t.Fatalf("boot outcome=%q err=%v", outcome, err) } finalState, err := loadSimulatedFirmwareBootState() if err != nil || finalState.Phase != test.wantPhase || finalState.ActiveSlot != test.wantSlot { t.Fatalf("final state=%+v err=%v", finalState, err) } if len(acks) != test.wantAckCount { t.Fatalf("ack count=%d acks=%+v", len(acks), acks) } lastResult := acks[len(acks)-1]["result_summary"].(map[string]any) if lastResult["result_code"] != test.wantCode { t.Fatalf("last result=%+v", lastResult) } }) } } func signedSimulatorFirmwarePayload(t *testing.T, content []byte, version string) map[string]any { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } digest := sha256.Sum256(content) digestText := hex.EncodeToString(digest[:]) manifest := fmt.Sprintf("firmware-manifest-v1\nmodel=simulated-terminal\nhardware_revision=sim-rev-a\nversion=%s\nsha256=%s\nsize=%d\n", version, digestText, len(content)) trustedKeys, _ := json.Marshal(map[string]string{"sim-test-key": base64.StdEncoding.EncodeToString(publicKey)}) t.Setenv("SIM_FIRMWARE_TRUSTED_KEYS", string(trustedKeys)) return map[string]any{ "release_id": "release-1", "version": version, "sha256": digestText, "signature": base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(manifest))), "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download", } }