---
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