--- phase: 05-cable-test-integration plan: "02" type: tdd wave: 2 depends_on: ["05-01"] files_modified: - internal/netbox/client.go - internal/netbox/types.go - internal/netbox/client_test.go - internal/api/handlers/test.go - internal/api/handlers/test_test.go - internal/api/router.go - cmd/hwlab/main.go autonomous: true requirements: [CBL-05, CBL-06, CBL-07] must_haves: truths: - "POST /api/test/cable accepts a TestResult JSON body, creates a NetBox cable record, returns {hw_id, netbox_id}" - "GET /api/test/events SSE streams live readings from any connected StreamingTesterDriver" - "GET /api/test/recent returns the last 20 test results from an in-memory ring buffer" - "Auto-detect: when USB Manager emits a DeviceEvent with Role=RoleCableTester, the correct driver type is activated" - "POST /api/test/cable auto-prints the cable label via the existing printer.PrinterDriver" - "Cable record test_data custom field receives structured JSON from TestResult" artifacts: - path: "internal/netbox/client.go" provides: "CreateCable method" exports: [CreateCable] - path: "internal/netbox/types.go" provides: "CableRecord type" exports: [CableRecord] - path: "internal/api/handlers/test.go" provides: "TestHandler (POST /api/test/cable, GET /api/test/events, GET /api/test/recent)" exports: [TestHandler] - path: "internal/api/router.go" provides: "Three new /api/test/* routes" key_links: - from: "POST /api/test/cable handler" to: "netbox.Client.CreateCable" via: "creates cable record with test_data JSON" - from: "POST /api/test/cable handler" to: "printer.PrinterDriver.Print" via: "auto-prints cable label after record creation (non-fatal)" - from: "GET /api/test/events handler" to: "tester.StreamingTesterDriver.Stream()" via: "SSE wraps LiveReading channel; goroutine exits on r.Context().Done()" --- Add `CreateCable` to the NetBox client, build `TestHandler` for the three cable-test API endpoints, wire them into the router, and connect auto-detection from the USB Manager to the correct tester driver. Purpose: Backend can receive test results, persist them to NetBox, stream live FNB58 data via SSE, and auto-print the cable label — all without hardware, using mock drivers. Output: internal/netbox/client.go (CreateCable), internal/api/handlers/test.go, updated router and main.go. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-cable-test-integration/05-CONTEXT.md @.planning/phases/05-cable-test-integration/05-01-SUMMARY.md @.planning/phases/04-usb-manager-label-printing/04-03-SUMMARY.md @internal/netbox/client.go @internal/netbox/types.go @internal/api/router.go @internal/api/handlers/usb_events.go @internal/tester/driver.go @internal/usb/device.go From internal/tester/driver.go (created in 05-01): ```go type CableType int const (CableTypeUSB CableType = iota; CableTypeDP; CableTypeHDMI) type TestResult struct { CableType CableType USBVersion string DPVersion string HDMIVersion string SpeedGbps float64 MaxWatts int PinContinuity bool HasEMarker bool ResistanceOhm float64 } type LiveReading struct { Voltage float64 CurrentAmps float64 PowerWatts float64 PDProtocol string Timestamp time.Time } type TesterDriver interface { Connect() error Read() (TestResult, error) Disconnect() error } type StreamingTesterDriver interface { TesterDriver Stream() <-chan LiveReading } ``` From internal/usb/device.go: ```go type DeviceRole int const (RolePrinter; RoleCableTester; RoleUnknown) type DeviceEvent struct { VIDPID string; Spec DeviceSpec; State DeviceState } type Manager struct { ... } func (m *Manager) Events() <-chan DeviceEvent ``` From internal/printer/driver.go: ```go type PrinterDriver interface { Connect() error Print(bitmap []byte, width, height int) error Disconnect() error } ``` From internal/api/handlers/usb_events.go (SSE pattern to mirror): ```go // USBEventsHandler pattern: // - 30s keepalive ticker // - select on r.Context().Done() for leak-safe exit // - w.Header().Set("Content-Type", "text/event-stream") // - w.Header().Set("Cache-Control", "no-cache") // - flusher := w.(http.Flusher) ``` From internal/api/router.go: ```go func NewRouter( staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler, labelHandler *handlers.LabelHandler, usbEventsHandler *handlers.USBEventsHandler, ) http.Handler // Add testHandler *handlers.TestHandler parameter ``` Task 1: NetBox CreateCable method + CableRecord type internal/netbox/client.go, internal/netbox/types.go, internal/netbox/client_test.go - CableRecord type: ID int, HWID string, Label string, TestData string (raw JSON), CatalogStatus string - CreateCable(ctx, label, assetTag, testDataJSON string) (int64, error) creates a cable in NetBox dcim cables API - CreateCable with empty label returns error "cable label must not be empty" - CreateCable marshals testDataJSON into the test_data custom field - CreateCable sets catalog_status custom field to "complete" - Integration test is build-tagged `//go:build integration` — unit tests use a mock HTTP server (httptest) RED: Write unit test using httptest.NewServer to mock the NetBox API. Test: CreateCable returns (id>0, nil) on 201; returns error on 422; rejects empty label. Run `go test ./internal/netbox/... -run TestCreateCable` — fail. GREEN: Add to internal/netbox/types.go: ```go type CableRecord struct { ID int HWID string Label string TestData string // raw JSON blob CatalogStatus string } ``` Add to internal/netbox/client.go: ```go func (c *Client) CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error) { if label == "" { return 0, fmt.Errorf("cable label must not be empty") } req := nb.NewWritableCableRequest() req.SetLabel(label) customFields := map[string]interface{}{ "test_data": testDataJSON, "catalog_status": "complete", } if assetTag != "" { customFields["hw_id"] = assetTag } req.SetCustomFields(customFields) result, _, err := c.api.DcimAPI.DcimCablesCreate(ctx). WritableCableRequest(*req).Execute() if err != nil { return 0, fmt.Errorf("CreateCable %q: %w", label, err) } return int64(result.GetId()), nil } ``` Note: go-netbox v4 cable API uses `DcimCablesCreate`. If the exact method name differs, inspect `c.api.DcimAPI` methods — the pattern is identical to `DcimDevicesCreate` used in CreateDevice. cd /home/mikkel/homelabby && go test ./internal/netbox/... -run TestCreateCable -v -count=1 CreateCable unit tests pass; go build ./... passes; integration test file exists with build tag (not run in CI). Task 2: TestHandler (3 endpoints) + router wiring + main.go init internal/api/handlers/test.go, internal/api/handlers/test_test.go, internal/api/router.go, cmd/hwlab/main.go - POST /api/test/cable: accepts JSON body {cable_type, usb_version, dp_version, hdmi_version, speed_gbps, max_watts, pin_continuity, has_emarker, resistance_ohm, hw_id}; creates NetBox cable record; auto-prints label (non-fatal — print failure does not 500); returns 201 {hw_id, netbox_id, print_skipped} - POST /api/test/cable with missing hw_id still succeeds (hw_id defaults to "") - POST /api/test/cable with malformed JSON returns 400 - GET /api/test/events: SSE with Content-Type text/event-stream; emits "data: {...LiveReading JSON}\n\n"; exits cleanly on client disconnect; 30s keepalive ticker; goroutine-leak-safe - GET /api/test/recent: returns JSON array of last ≤20 TestResult entries from in-memory ring buffer; empty returns [] - TestHandler has NetBoxClient interface (subset: CreateCable only) for test injection - TestHandler has PrinterDriver interface for injection (mirrors label handler pattern) - Ring buffer capacity: 20, thread-safe with sync.Mutex RED: Write test_test.go covering: POST /api/test/cable success (mock NetBox + mock printer), POST malformed body 400, GET /api/test/recent empty returns [], GET /api/test/events emits SSE + closes on disconnect. Run — fail. GREEN: Create internal/api/handlers/test.go: ```go package handlers import ( "encoding/json" "net/http" "sync" "time" "git.georgsen.dk/hwlab/internal/tester" ) // TestNetBoxClient is the subset of netbox.Client used by TestHandler. type TestNetBoxClient interface { CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error) } type TestHandler struct { nb TestNetBoxClient printer printer.PrinterDriver mu sync.Mutex recent []tester.TestResult // ring buffer, cap 20 liveCh chan tester.LiveReading // set by AttachStream } func NewTestHandler(nb TestNetBoxClient, p printer.PrinterDriver) *TestHandler { return &TestHandler{nb: nb, printer: p, liveCh: make(chan tester.LiveReading, 64)} } // AttachStream wires a StreamingTesterDriver's channel to the SSE broadcaster. func (h *TestHandler) AttachStream(ch <-chan tester.LiveReading) { ... } ``` POST /api/test/cable handler: 1. Decode JSON body into TestResult struct. 2. Marshal TestResult to JSON for test_data field. 3. Derive label from CableType + USBVersion/DPVersion/HDMIVersion. 4. Call h.nb.CreateCable(ctx, label, req.HWID, testDataJSON). 5. Attempt label print via h.printer (RenderCable + ImageToRawBitmap + Print); on error set print_skipped=true, log, continue. 6. Prepend to recent ring buffer (cap 20, drop oldest). 7. Return 201 JSON. GET /api/test/events: mirror usb_events.go pattern exactly — Content-Type text/event-stream, 30s keepalive, select on r.Context().Done() and h.liveCh. GET /api/test/recent: lock mutex, copy recent slice, unlock, write JSON. Update internal/api/router.go: add `testHandler *handlers.TestHandler` param, register three routes: - `r.Post("/test/cable", testHandler.SubmitCableTest)` - `r.Get("/test/events", testHandler.StreamEvents)` - `r.Get("/test/recent", testHandler.RecentTests)` Update cmd/hwlab/main.go: construct TestHandler with netboxClient (real) and printer.NewMockDriver(); pass to NewRouter(). Add goroutine that reads usbManager.Events() and calls testHandler.AttachStream() when a RoleCableTester device connects (no-op for now — wires the plumbing). cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestTestHandler -v -count=1 -race All TestHandler tests pass; go build ./... passes; race-clean; three /api/test/* routes reachable via `go run ./cmd/hwlab` (smoke-test with curl if available). ## Trust Boundaries | Boundary | Description | |----------|-------------| | client → POST /api/test/cable | JSON body from browser — untrusted user input | | SSE stream → GET /api/test/events | Server push; client can disconnect at any time | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-05-03 | Tampering | POST /api/test/cable JSON body | mitigate | json.Decoder with DisallowUnknownFields; return 400 on malformed input | | T-05-04 | DoS | GET /api/test/events goroutine leak | mitigate | Select on r.Context().Done(); 30s keepalive ticker matches usb_events.go pattern | | T-05-05 | DoS | POST /api/test/cable runaway print calls | mitigate | Inherit 1-print/s rate limit from LabelHandler pattern (PrintCooldown); return 429 if violated | | T-05-06 | Information Disclosure | test_data JSON stored in NetBox | accept | LAN-only deployment; NetBox has its own auth; no PII in cable test data | ``` go build ./... go test ./internal/netbox/... -run TestCreateCable -v go test ./internal/api/handlers/... -race -v curl -s -X POST http://localhost:8080/api/test/cable \ -H "Content-Type: application/json" \ -d '{"cable_type":0,"usb_version":"USB 3.2 Gen 2","speed_gbps":10,"max_watts":100,"pin_continuity":true}' \ | jq . curl -s http://localhost:8080/api/test/recent | jq . ``` - CreateCable method exists on netbox.Client, unit tests pass - POST /api/test/cable: creates NetBox record, auto-prints (non-fatal), returns 201 with {hw_id, netbox_id, print_skipped} - GET /api/test/events: SSE, goroutine-leak-safe, 30s keepalive - GET /api/test/recent: returns last 20 results, empty array default - All three routes registered in router - Existing tests (phases 1-4) continue to pass After completion, create `.planning/phases/05-cable-test-integration/05-02-SUMMARY.md`