diff --git a/.planning/phases/05-cable-test-integration/05-02-SUMMARY.md b/.planning/phases/05-cable-test-integration/05-02-SUMMARY.md new file mode 100644 index 0000000..07c39e7 --- /dev/null +++ b/.planning/phases/05-cable-test-integration/05-02-SUMMARY.md @@ -0,0 +1,123 @@ +--- +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