Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

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