Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

623 lines
20KB

  1. package control
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  11. "github.com/jan/fm-rds-tx/internal/output"
  12. )
  13. func TestHealthz(t *testing.T) {
  14. srv := NewServer(cfgpkg.Default())
  15. rec := httptest.NewRecorder()
  16. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
  17. if rec.Code != 200 {
  18. t.Fatalf("status: %d", rec.Code)
  19. }
  20. }
  21. func TestStatus(t *testing.T) {
  22. srv := NewServer(cfgpkg.Default())
  23. rec := httptest.NewRecorder()
  24. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  25. if rec.Code != 200 {
  26. t.Fatalf("status: %d", rec.Code)
  27. }
  28. var body map[string]any
  29. json.Unmarshal(rec.Body.Bytes(), &body)
  30. if body["service"] != "fm-rds-tx" {
  31. t.Fatal("missing service")
  32. }
  33. if _, ok := body["preEmphasisTauUS"]; !ok {
  34. t.Fatal("missing preEmphasisTauUS")
  35. }
  36. }
  37. func TestStatusReportsRuntimeIndicator(t *testing.T) {
  38. srv := NewServer(cfgpkg.Default())
  39. srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded", "runtimeAlert": "late buffers"}})
  40. rec := httptest.NewRecorder()
  41. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  42. if rec.Code != 200 {
  43. t.Fatalf("status: %d", rec.Code)
  44. }
  45. var body map[string]any
  46. json.Unmarshal(rec.Body.Bytes(), &body)
  47. if body["runtimeIndicator"] != "degraded" {
  48. t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"])
  49. }
  50. if body["runtimeAlert"] != "late buffers" {
  51. t.Fatalf("expected runtimeAlert late buffers, got %v", body["runtimeAlert"])
  52. }
  53. }
  54. func TestStatusReportsQueueStats(t *testing.T) {
  55. cfg := cfgpkg.Default()
  56. queueStats := output.QueueStats{
  57. Capacity: cfg.Runtime.FrameQueueCapacity,
  58. Depth: 1,
  59. FillLevel: 0.25,
  60. Health: output.QueueHealthLow,
  61. }
  62. srv := NewServer(cfg)
  63. srv.SetTXController(&fakeTXController{stats: map[string]any{"queue": queueStats}})
  64. rec := httptest.NewRecorder()
  65. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  66. if rec.Code != 200 {
  67. t.Fatalf("status: %d", rec.Code)
  68. }
  69. var body map[string]any
  70. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  71. t.Fatalf("unmarshal queue stats: %v", err)
  72. }
  73. queueRaw, ok := body["queue"]
  74. if !ok {
  75. t.Fatalf("missing queue in status")
  76. }
  77. queueMap, ok := queueRaw.(map[string]any)
  78. if !ok {
  79. t.Fatalf("queue stats type mismatch: %T", queueRaw)
  80. }
  81. if queueMap["capacity"] != float64(queueStats.Capacity) {
  82. t.Fatalf("queue capacity mismatch: want %v got %v", queueStats.Capacity, queueMap["capacity"])
  83. }
  84. if queueMap["health"] != string(queueStats.Health) {
  85. t.Fatalf("queue health mismatch: want %s got %v", queueStats.Health, queueMap["health"])
  86. }
  87. }
  88. func TestStatusReportsRuntimeState(t *testing.T) {
  89. srv := NewServer(cfgpkg.Default())
  90. srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}})
  91. rec := httptest.NewRecorder()
  92. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  93. if rec.Code != 200 {
  94. t.Fatalf("status: %d", rec.Code)
  95. }
  96. var body map[string]any
  97. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  98. t.Fatalf("unmarshal runtime state: %v", err)
  99. }
  100. if body["runtimeState"] != "faulted" {
  101. t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"])
  102. }
  103. }
  104. func TestDryRunEndpoint(t *testing.T) {
  105. srv := NewServer(cfgpkg.Default())
  106. rec := httptest.NewRecorder()
  107. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil))
  108. if rec.Code != 200 {
  109. t.Fatalf("status: %d", rec.Code)
  110. }
  111. var body map[string]any
  112. json.Unmarshal(rec.Body.Bytes(), &body)
  113. if body["mode"] != "dry-run" {
  114. t.Fatal("wrong mode")
  115. }
  116. }
  117. func TestConfigPatch(t *testing.T) {
  118. srv := NewServer(cfgpkg.Default())
  119. body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`)
  120. rec := httptest.NewRecorder()
  121. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  122. if rec.Code != 200 {
  123. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  124. }
  125. }
  126. func TestConfigPatchRejectsOversizeBody(t *testing.T) {
  127. srv := NewServer(cfgpkg.Default())
  128. rec := httptest.NewRecorder()
  129. payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32)
  130. body := append([]byte(`{"ps":"`), payload...)
  131. body = append(body, []byte(`"}`)...)
  132. req := newConfigPostRequest(body)
  133. srv.Handler().ServeHTTP(rec, req)
  134. if rec.Code != http.StatusRequestEntityTooLarge {
  135. t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String())
  136. }
  137. }
  138. func TestConfigPatchRejectsMissingContentType(t *testing.T) {
  139. srv := NewServer(cfgpkg.Default())
  140. rec := httptest.NewRecorder()
  141. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
  142. srv.Handler().ServeHTTP(rec, req)
  143. if rec.Code != http.StatusUnsupportedMediaType {
  144. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  145. }
  146. }
  147. func TestConfigPatchRejectsNonJSONContentType(t *testing.T) {
  148. srv := NewServer(cfgpkg.Default())
  149. rec := httptest.NewRecorder()
  150. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
  151. req.Header.Set("Content-Type", "text/plain")
  152. srv.Handler().ServeHTTP(rec, req)
  153. if rec.Code != http.StatusUnsupportedMediaType {
  154. t.Fatalf("expected 415 for non-JSON Content-Type, got %d", rec.Code)
  155. }
  156. }
  157. func TestRuntimeWithoutDriver(t *testing.T) {
  158. srv := NewServer(cfgpkg.Default())
  159. rec := httptest.NewRecorder()
  160. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  161. if rec.Code != 200 {
  162. t.Fatalf("status: %d", rec.Code)
  163. }
  164. }
  165. func TestRuntimeReportsFaultHistory(t *testing.T) {
  166. srv := NewServer(cfgpkg.Default())
  167. history := []map[string]any{
  168. {
  169. "time": "2026-04-06T00:00:00Z",
  170. "reason": "queueCritical",
  171. "severity": "faulted",
  172. "message": "queue critical",
  173. },
  174. }
  175. srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
  176. rec := httptest.NewRecorder()
  177. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  178. if rec.Code != 200 {
  179. t.Fatalf("status: %d", rec.Code)
  180. }
  181. var body map[string]any
  182. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  183. t.Fatalf("unmarshal runtime: %v", err)
  184. }
  185. engineRaw, ok := body["engine"].(map[string]any)
  186. if !ok {
  187. t.Fatalf("runtime engine missing")
  188. }
  189. histRaw, ok := engineRaw["faultHistory"].([]any)
  190. if !ok {
  191. t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
  192. }
  193. if len(histRaw) != len(history) {
  194. t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
  195. }
  196. }
  197. func TestRuntimeReportsTransitionHistory(t *testing.T) {
  198. srv := NewServer(cfgpkg.Default())
  199. history := []map[string]any{{
  200. "time": "2026-04-06T00:00:00Z",
  201. "from": "running",
  202. "to": "degraded",
  203. "severity": "warn",
  204. }}
  205. srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
  206. rec := httptest.NewRecorder()
  207. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  208. if rec.Code != 200 {
  209. t.Fatalf("status: %d", rec.Code)
  210. }
  211. var body map[string]any
  212. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  213. t.Fatalf("unmarshal runtime: %v", err)
  214. }
  215. engineRaw, ok := body["engine"].(map[string]any)
  216. if !ok {
  217. t.Fatalf("runtime engine missing")
  218. }
  219. histRaw, ok := engineRaw["transitionHistory"].([]any)
  220. if !ok {
  221. t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
  222. }
  223. if len(histRaw) != len(history) {
  224. t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
  225. }
  226. }
  227. func TestRuntimeFaultResetRejectsGet(t *testing.T) {
  228. srv := NewServer(cfgpkg.Default())
  229. rec := httptest.NewRecorder()
  230. req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
  231. srv.Handler().ServeHTTP(rec, req)
  232. if rec.Code != http.StatusMethodNotAllowed {
  233. t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
  234. }
  235. }
  236. func TestRuntimeFaultResetRequiresController(t *testing.T) {
  237. srv := NewServer(cfgpkg.Default())
  238. rec := httptest.NewRecorder()
  239. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  240. srv.Handler().ServeHTTP(rec, req)
  241. if rec.Code != http.StatusServiceUnavailable {
  242. t.Fatalf("expected 503 without controller, got %d", rec.Code)
  243. }
  244. }
  245. func TestRuntimeFaultResetControllerError(t *testing.T) {
  246. srv := NewServer(cfgpkg.Default())
  247. srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
  248. rec := httptest.NewRecorder()
  249. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  250. srv.Handler().ServeHTTP(rec, req)
  251. if rec.Code != http.StatusConflict {
  252. t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
  253. }
  254. }
  255. func TestRuntimeFaultResetSuccess(t *testing.T) {
  256. srv := NewServer(cfgpkg.Default())
  257. srv.SetTXController(&fakeTXController{})
  258. rec := httptest.NewRecorder()
  259. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  260. srv.Handler().ServeHTTP(rec, req)
  261. if rec.Code != 200 {
  262. t.Fatalf("expected 200 on success, got %d", rec.Code)
  263. }
  264. var body map[string]any
  265. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  266. t.Fatalf("unmarshal response: %v", err)
  267. }
  268. if ok, _ := body["ok"].(bool); !ok {
  269. t.Fatalf("expected ok true, got %v", body["ok"])
  270. }
  271. }
  272. func TestRuntimeFaultResetRejectsBody(t *testing.T) {
  273. srv := NewServer(cfgpkg.Default())
  274. srv.SetTXController(&fakeTXController{})
  275. rec := httptest.NewRecorder()
  276. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope")))
  277. srv.Handler().ServeHTTP(rec, req)
  278. if rec.Code != http.StatusBadRequest {
  279. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  280. }
  281. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  282. t.Fatalf("unexpected response body: %q", rec.Body.String())
  283. }
  284. }
  285. func TestAudioStreamRequiresSource(t *testing.T) {
  286. srv := NewServer(cfgpkg.Default())
  287. rec := httptest.NewRecorder()
  288. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
  289. req.Header.Set("Content-Type", "application/octet-stream")
  290. srv.Handler().ServeHTTP(rec, req)
  291. if rec.Code != http.StatusServiceUnavailable {
  292. t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
  293. }
  294. }
  295. func TestAudioStreamPushesPCM(t *testing.T) {
  296. cfg := cfgpkg.Default()
  297. srv := NewServer(cfg)
  298. ingress := &fakeAudioIngress{}
  299. srv.SetAudioIngress(ingress)
  300. pcm := []byte{0, 0, 0, 0}
  301. rec := httptest.NewRecorder()
  302. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
  303. req.Header.Set("Content-Type", "application/octet-stream")
  304. srv.Handler().ServeHTTP(rec, req)
  305. if rec.Code != 200 {
  306. t.Fatalf("expected 200, got %d", rec.Code)
  307. }
  308. var body map[string]any
  309. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  310. t.Fatalf("unmarshal response: %v", err)
  311. }
  312. if ok, _ := body["ok"].(bool); !ok {
  313. t.Fatalf("expected ok true, got %v", body["ok"])
  314. }
  315. frames, _ := body["frames"].(float64)
  316. if frames != 1 {
  317. t.Fatalf("expected 1 frame, got %v", frames)
  318. }
  319. if ingress.totalFrames != 1 {
  320. t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames)
  321. }
  322. }
  323. func TestAudioStreamRejectsNonPost(t *testing.T) {
  324. srv := NewServer(cfgpkg.Default())
  325. rec := httptest.NewRecorder()
  326. req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
  327. srv.Handler().ServeHTTP(rec, req)
  328. if rec.Code != http.StatusMethodNotAllowed {
  329. t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
  330. }
  331. }
  332. func TestAudioStreamRejectsMissingContentType(t *testing.T) {
  333. cfg := cfgpkg.Default()
  334. srv := NewServer(cfg)
  335. srv.SetAudioIngress(&fakeAudioIngress{})
  336. rec := httptest.NewRecorder()
  337. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  338. srv.Handler().ServeHTTP(rec, req)
  339. if rec.Code != http.StatusUnsupportedMediaType {
  340. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  341. }
  342. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  343. t.Fatalf("unexpected response body: %q", rec.Body.String())
  344. }
  345. }
  346. func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
  347. cfg := cfgpkg.Default()
  348. srv := NewServer(cfg)
  349. srv.SetAudioIngress(&fakeAudioIngress{})
  350. rec := httptest.NewRecorder()
  351. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  352. req.Header.Set("Content-Type", "text/plain")
  353. srv.Handler().ServeHTTP(rec, req)
  354. if rec.Code != http.StatusUnsupportedMediaType {
  355. t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code)
  356. }
  357. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  358. t.Fatalf("unexpected response body: %q", rec.Body.String())
  359. }
  360. }
  361. func TestAudioStreamRejectsBodyTooLarge(t *testing.T) {
  362. orig := audioStreamBodyLimit
  363. t.Cleanup(func() {
  364. audioStreamBodyLimit = orig
  365. })
  366. audioStreamBodyLimit = 1024
  367. limit := int(audioStreamBodyLimit)
  368. body := make([]byte, limit+1)
  369. srv := NewServer(cfgpkg.Default())
  370. srv.SetAudioIngress(&fakeAudioIngress{})
  371. rec := httptest.NewRecorder()
  372. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body))
  373. req.Header.Set("Content-Type", "application/octet-stream")
  374. srv.Handler().ServeHTTP(rec, req)
  375. if rec.Code != http.StatusRequestEntityTooLarge {
  376. t.Fatalf("expected 413 for oversized body, got %d", rec.Code)
  377. }
  378. if !strings.Contains(rec.Body.String(), "request body too large") {
  379. t.Fatalf("unexpected response body: %q", rec.Body.String())
  380. }
  381. }
  382. func TestTXStartWithoutController(t *testing.T) {
  383. srv := NewServer(cfgpkg.Default())
  384. rec := httptest.NewRecorder()
  385. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
  386. if rec.Code != http.StatusServiceUnavailable {
  387. t.Fatalf("expected 503, got %d", rec.Code)
  388. }
  389. }
  390. func TestTXStartRejectsBody(t *testing.T) {
  391. srv := NewServer(cfgpkg.Default())
  392. srv.SetTXController(&fakeTXController{})
  393. rec := httptest.NewRecorder()
  394. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  395. srv.Handler().ServeHTTP(rec, req)
  396. if rec.Code != http.StatusBadRequest {
  397. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  398. }
  399. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  400. t.Fatalf("unexpected response body: %q", rec.Body.String())
  401. }
  402. }
  403. func TestTXStopRejectsBody(t *testing.T) {
  404. srv := NewServer(cfgpkg.Default())
  405. srv.SetTXController(&fakeTXController{})
  406. rec := httptest.NewRecorder()
  407. req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body")))
  408. srv.Handler().ServeHTTP(rec, req)
  409. if rec.Code != http.StatusBadRequest {
  410. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  411. }
  412. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  413. t.Fatalf("unexpected response body: %q", rec.Body.String())
  414. }
  415. }
  416. func TestConfigPatchUpdatesSnapshot(t *testing.T) {
  417. srv := NewServer(cfgpkg.Default())
  418. srv.SetTXController(&fakeTXController{})
  419. rec := httptest.NewRecorder()
  420. body := []byte(`{"outputDrive":1.2}`)
  421. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  422. if rec.Code != 200 {
  423. t.Fatalf("status: %d", rec.Code)
  424. }
  425. var resp map[string]any
  426. if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  427. t.Fatalf("unmarshal response: %v", err)
  428. }
  429. if live, ok := resp["live"].(bool); !ok || !live {
  430. t.Fatalf("expected live true, got %v", resp["live"])
  431. }
  432. rec = httptest.NewRecorder()
  433. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  434. var cfg cfgpkg.Config
  435. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  436. t.Fatalf("decode config: %v", err)
  437. }
  438. if cfg.FM.OutputDrive != 1.2 {
  439. t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
  440. }
  441. }
  442. func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
  443. srv := NewServer(cfgpkg.Default())
  444. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  445. body := []byte(`{"outputDrive":2.2}`)
  446. rec := httptest.NewRecorder()
  447. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  448. if rec.Code != http.StatusBadRequest {
  449. t.Fatalf("expected 400, got %d", rec.Code)
  450. }
  451. rec = httptest.NewRecorder()
  452. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  453. var cfg cfgpkg.Config
  454. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  455. t.Fatalf("decode config: %v", err)
  456. }
  457. if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
  458. t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
  459. }
  460. }
  461. func TestRuntimeIncludesControlAudit(t *testing.T) {
  462. srv := NewServer(cfgpkg.Default())
  463. counts := controlAuditCounts(t, srv)
  464. keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"}
  465. for _, key := range keys {
  466. if counts[key] != 0 {
  467. t.Fatalf("expected %s to start at 0, got %d", key, counts[key])
  468. }
  469. }
  470. }
  471. func TestControlAuditTracksMethodNotAllowed(t *testing.T) {
  472. srv := NewServer(cfgpkg.Default())
  473. rec := httptest.NewRecorder()
  474. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil))
  475. if rec.Code != http.StatusMethodNotAllowed {
  476. t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code)
  477. }
  478. counts := controlAuditCounts(t, srv)
  479. if counts["methodNotAllowed"] != 1 {
  480. t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"])
  481. }
  482. }
  483. func TestControlAuditTracksUnsupportedMediaType(t *testing.T) {
  484. srv := NewServer(cfgpkg.Default())
  485. srv.SetAudioIngress(&fakeAudioIngress{})
  486. rec := httptest.NewRecorder()
  487. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  488. srv.Handler().ServeHTTP(rec, req)
  489. if rec.Code != http.StatusUnsupportedMediaType {
  490. t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code)
  491. }
  492. counts := controlAuditCounts(t, srv)
  493. if counts["unsupportedMediaType"] != 1 {
  494. t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"])
  495. }
  496. }
  497. func TestControlAuditTracksBodyTooLarge(t *testing.T) {
  498. srv := NewServer(cfgpkg.Default())
  499. limit := int(maxConfigBodyBytes)
  500. body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}")
  501. rec := httptest.NewRecorder()
  502. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  503. if rec.Code != http.StatusRequestEntityTooLarge {
  504. t.Fatalf("expected 413 for oversized config body, got %d", rec.Code)
  505. }
  506. counts := controlAuditCounts(t, srv)
  507. if counts["bodyTooLarge"] != 1 {
  508. t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"])
  509. }
  510. }
  511. func TestControlAuditTracksUnexpectedBody(t *testing.T) {
  512. srv := NewServer(cfgpkg.Default())
  513. srv.SetTXController(&fakeTXController{})
  514. rec := httptest.NewRecorder()
  515. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  516. srv.Handler().ServeHTTP(rec, req)
  517. if rec.Code != http.StatusBadRequest {
  518. t.Fatalf("expected 400 for unexpected body, got %d", rec.Code)
  519. }
  520. counts := controlAuditCounts(t, srv)
  521. if counts["unexpectedBody"] != 1 {
  522. t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"])
  523. }
  524. }
  525. func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 {
  526. t.Helper()
  527. rec := httptest.NewRecorder()
  528. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  529. if rec.Code != http.StatusOK {
  530. t.Fatalf("runtime request failed: %d", rec.Code)
  531. }
  532. var payload map[string]any
  533. if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
  534. t.Fatalf("unmarshal runtime: %v", err)
  535. }
  536. raw, ok := payload["controlAudit"].(map[string]any)
  537. if !ok {
  538. t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"])
  539. }
  540. counts := map[string]uint64{}
  541. for key, value := range raw {
  542. num, ok := value.(float64)
  543. if !ok {
  544. t.Fatalf("controlAudit %s not numeric: %T", key, value)
  545. }
  546. counts[key] = uint64(num)
  547. }
  548. return counts
  549. }
  550. func newConfigPostRequest(body []byte) *http.Request {
  551. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
  552. req.Header.Set("Content-Type", "application/json")
  553. return req
  554. }
  555. type fakeTXController struct {
  556. updateErr error
  557. resetErr error
  558. stats map[string]any
  559. }
  560. type fakeAudioIngress struct {
  561. totalFrames int
  562. }
  563. func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) {
  564. frames := len(data) / 4
  565. f.totalFrames += frames
  566. return frames, nil
  567. }
  568. func (f *fakeTXController) StartTX() error { return nil }
  569. func (f *fakeTXController) StopTX() error { return nil }
  570. func (f *fakeTXController) TXStats() map[string]any {
  571. if f.stats != nil {
  572. return f.stats
  573. }
  574. return map[string]any{}
  575. }
  576. func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
  577. func (f *fakeTXController) ResetFault() error { return f.resetErr }