14 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-cable-test-integration | 02 | tdd | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.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):
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:
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:
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):
// 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:
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).
<threat_model>
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 |
| </threat_model> |
<success_criteria>
- 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 </success_criteria>