--- phase: 05-cable-test-integration plan: "02" subsystem: netbox-client, api-handlers, router tags: [netbox, cable-test, sse, tdd, handler] dependency_graph: requires: ["05-01"] provides: ["CreateCable", "TestHandler", "/api/test/cable", "/api/test/events", "/api/test/recent"] affects: ["internal/netbox/client.go", "internal/api/router.go", "cmd/hwlab/main.go"] tech_stack: added: [] patterns: - "TestNetBoxClient interface for handler injection (mirrors IntakeNetBoxClient pattern)" - "TestPrinter interface for nil-safe auto-print with 1s rate limit (mirrors LabelHandler)" - "SSE handler mirrors usb_events.go: 30s keepalive, r.Context().Done() exit" - "Ring buffer capped at 20 items, prepend-newest, sync.Mutex protected" - "httptest.NewUnstartedServer for mock responses needing srv.URL in handler" key_files: created: - internal/api/handlers/test.go - internal/api/handlers/test_test.go modified: - internal/netbox/client.go - internal/netbox/types.go - internal/netbox/client_test.go - internal/api/router.go - cmd/hwlab/main.go decisions: - "Used DisallowUnknownFields on JSON decoder for POST /api/test/cable (T-05-03 mitigation)" - "Print rate limit 1s inherits LabelHandler pattern (T-05-05 mitigation)" - "liveCh is a buffered channel (cap 64) owned by TestHandler; AttachStream fans in from driver channels" - "deriveCableLabel builds label from CableType + version string — cable names are descriptive not unique" - "httptest.NewUnstartedServer used in cable test mock so srv.URL is capturable in handler closure" metrics: duration_minutes: 25 completed_date: "2026-04-10" tasks_completed: 2 tasks_total: 2 files_created: 2 files_modified: 5 --- # Phase 05 Plan 02: NetBox CreateCable + TestHandler + 3 Endpoints Summary **One-liner:** NetBox cable creation via `DcimCablesCreate` with `test_data`/`catalog_status` custom fields, plus `TestHandler` providing SSE live readings, 20-item ring buffer, and auto-print with 1s rate limit on three `/api/test/*` routes. ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | 1 | NetBox CreateCable method + CableRecord type | `7908d40` | internal/netbox/client.go, types.go, client_test.go | | 2 | TestHandler (3 endpoints) + router wiring + main.go init | `1764c7b` | handlers/test.go, test_test.go, router.go, main.go | ## What Was Built ### Task 1: CreateCable + CableRecord - `CableRecord` type added to `internal/netbox/types.go` with ID, HWID, Label, TestData (raw JSON), CatalogStatus fields - `CreateCable(ctx, label, assetTag, testDataJSON string) (int64, error)` on `*netbox.Client` - Uses `nb.NewWritableCableRequest()` → `DcimCablesCreate` (mirrors `CreateDevice` pattern) - Sets `test_data` and `catalog_status: "complete"` custom fields; `hw_id` if assetTag non-empty - Rejects empty label with sentinel error `"cable label must not be empty"` - Unit tests: empty label rejection, 201 success (httptest mock with required `url`/`display` fields), 422 error ### Task 2: TestHandler + Router + main.go - `TestHandler` struct with `TestNetBoxClient` and `TestPrinter` interfaces for injection - `POST /api/test/cable`: decodes JSON with `DisallowUnknownFields` (T-05-03), derives label via `deriveCableLabel`, calls `CreateCable`, attempts auto-print with 1s cooldown (T-05-05), prepends to 20-item ring buffer, returns `{hw_id, netbox_id, print_skipped}` - `GET /api/test/events`: SSE with `Content-Type: text/event-stream`, 30s keepalive ticker, exits on `r.Context().Done()` (T-05-04 goroutine-leak-safe) - `GET /api/test/recent`: mutex-locked copy of ring buffer, returns `[]` when empty - `AttachStream(ch <-chan tester.LiveReading)`: fans in from driver channel to internal buffered channel - Router: `NewRouter` signature extended with `*handlers.TestHandler`, three routes registered - main.go: `testHandler` constructed with `nbClient` + `mockDriver`; USB Manager goroutine wired (stub logs `RoleCableTester` connect events) ## Verification ``` go build ./... PASS go test ./internal/netbox/... -run TestCreateCable -v 3/3 PASS go test ./internal/api/handlers/... -race -v 7/7 PASS (TestTestHandler_*) go test ./... -race All packages PASS ``` ## Threat Mitigations Applied | Threat ID | Mitigation | Implementation | |-----------|-----------|----------------| | T-05-03 | Tampering — JSON body | `json.NewDecoder(...).DisallowUnknownFields()` on POST /api/test/cable | | T-05-04 | DoS — SSE goroutine leak | `select` on `r.Context().Done()` in `StreamEvents` | | T-05-05 | DoS — runaway print calls | 1s `printCooldown` in `TestHandler`, same pattern as `LabelHandler` | ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] go-netbox Cable response model requires `url` and `display` fields** - **Found during:** Task 1, GREEN phase — `TestCreateCable_Success` failed with "no value given for required property url" - **Issue:** The `Cable` response model (generated from NetBox OpenAPI spec) validates required properties `id`, `url`, `display` during JSON unmarshal. Mock returning only `{id, label}` triggered validation error. - **Fix:** Updated mock to return `url` and `display` fields; switched to `httptest.NewUnstartedServer` so `srv.URL` is capturable for a realistic URL value in the response. - **Files modified:** `internal/netbox/client_test.go` - **Commit:** `7908d40` ## Known Stubs - `main.go` USB Manager goroutine for `RoleCableTester`: logs connection event, does NOT construct a real tester driver or call `AttachStream`. Comment marks the `TODO(hardware)` for Phase 5 hardware integration. This does not prevent the plan's goal — `AttachStream` is wired and tested; hardware arrival is the blocker. ## Threat Flags None — no new network endpoints, auth paths, or file access patterns beyond those specified in the plan's threat model. ## Self-Check: PASSED - [x] `internal/netbox/client.go` — CreateCable method exists - [x] `internal/netbox/types.go` — CableRecord type exists - [x] `internal/netbox/client_test.go` — 3 CreateCable unit tests - [x] `internal/api/handlers/test.go` — TestHandler with 3 endpoints - [x] `internal/api/handlers/test_test.go` — 7 test cases - [x] `internal/api/router.go` — NewRouter has testHandler param, 3 routes - [x] `cmd/hwlab/main.go` — testHandler constructed and passed to NewRouter - [x] Commit `7908d40` exists (Task 1) - [x] Commit `1764c7b` exists (Task 2) - [x] `go build ./...` passes - [x] `go test ./... -race` all pass