docs(05): create phase 5 plans — tester drivers, backend endpoints, cable test UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:04:57 +00:00
parent 468f8930d1
commit 984f67834f
4 changed files with 842 additions and 3 deletions

View file

@ -106,8 +106,12 @@ Plans:
2. USB, DisplayPort, and HDMI cable test results (continuity, version, eMarker, PD, resistance) are displayed in the Cable Test Station UI and written to the NetBox cable record as structured JSON
3. FNIRSI FNB58 live voltage/current/PD protocol data streams to the UI and is persisted to the cable record
4. A cable can go from test to label printed in under 30 seconds using the rapid workflow
**Plans**: TBD
**UI hint**: yes
**Plans**: 3 plans
Plans:
- [ ] 05-01-PLAN.md — Tester driver package: TesterDriver interface, TestResult/LiveReading types, mock USB/DP/HDMI/FNB58 drivers, VID:PID registry entries
- [ ] 05-02-PLAN.md — Backend: CreateCable on NetBox client, TestHandler (POST /api/test/cable, GET /api/test/events SSE, GET /api/test/recent), router + main.go wiring
- [ ] 05-03-PLAN.md — Frontend: Cable Test Station page at /test (three-panel layout, Print & Next workflow, SSE live readout, mobile-responsive)
### Phase 6: Lab Advisor
**Goal**: Users can ask strategic homelab questions and receive streaming answers from Claude Opus with full inventory context, with conversation history persisted across sessions
@ -144,6 +148,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
| 5. Cable Test Integration | 0/TBD | Not started | - |
| 5. Cable Test Integration | 0/3 | Not started | - |
| 6. Lab Advisor | 0/TBD | Not started | - |
| 7. Research Agent & Search | 0/TBD | Not started | - |

View file

@ -0,0 +1,246 @@
---
phase: 05-cable-test-integration
plan: "01"
type: tdd
wave: 1
depends_on: []
files_modified:
- internal/tester/driver.go
- internal/tester/mock_drivers.go
- internal/tester/driver_test.go
- internal/usb/device.go
autonomous: true
requirements: [CBL-01, CBL-02, CBL-03, CBL-04, CBL-07]
must_haves:
truths:
- "TesterDriver interface exists with Connect/Read/Disconnect/StreamLive methods"
- "TestResult struct captures all cable fields (type, version, speed, power, continuity, eMarker, resistance)"
- "MockUSBDriver, MockDPDriver, MockHDMIDriver return deterministic test data without hardware"
- "MockFNB58Driver emits a stream of LiveReading samples via a channel"
- "KnownDevices in usb/device.go has placeholder entries for all 4 testers (RoleCableTester)"
- "All mock drivers satisfy the TesterDriver interface at compile time"
artifacts:
- path: "internal/tester/driver.go"
provides: "TesterDriver interface, TestResult, LiveReading, CableType constants"
exports: [TesterDriver, TestResult, LiveReading, CableType]
- path: "internal/tester/mock_drivers.go"
provides: "MockUSBDriver, MockDPDriver, MockHDMIDriver, MockFNB58Driver"
- path: "internal/tester/driver_test.go"
provides: "Tests for all four mock drivers"
- path: "internal/usb/device.go"
provides: "KnownDevices entries for all four tester VID:PIDs"
key_links:
- from: "internal/usb/device.go KnownDevices"
to: "internal/tester/driver.go CableType"
via: "VID:PID keys map to CableType constants for auto-detection"
- from: "MockFNB58Driver.Stream()"
to: "chan LiveReading"
via: "channel closed on Disconnect()"
---
<objective>
Create the `internal/tester` package: TesterDriver interface, TestResult/LiveReading types, four mock implementations (TreedixUSB/DP/HDMI + FNB58), and VID:PID placeholder entries in the USB device registry.
Purpose: All cable test code builds and tests pass without hardware. Real driver implementations are drop-in replacements when hardware arrives 2026-04-13.
Output: internal/tester package (driver.go + mock_drivers.go + driver_test.go), updated internal/usb/device.go.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md
@.planning/phases/05-cable-test-integration/05-CONTEXT.md
@internal/usb/device.go
@internal/printer/driver.go
</context>
<interfaces>
<!-- Key types from existing packages that tester package must integrate with. -->
From internal/usb/device.go:
```go
type DeviceRole int
const (
RolePrinter DeviceRole = iota
RoleCableTester // reserved for Phase 5
RoleUnknown
)
type DeviceSpec struct {
VID string
PID string
Name string
Role DeviceRole
BaudRate int
}
// KnownDevices — add 4 tester entries here (placeholder VID:PIDs)
var KnownDevices = map[string]DeviceSpec{
"0525:a4a7": {VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter, BaudRate: 9600},
// TODO Phase 5: Treedix testers placeholder entries go here
}
```
From internal/printer/driver.go (pattern to follow):
```go
type PrinterDriver interface {
Connect() error
Print(bitmap []byte, width, height int) error
Disconnect() error
}
```
</interfaces>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: TesterDriver interface, TestResult types, mock discrete drivers (USB/DP/HDMI)</name>
<files>internal/tester/driver.go, internal/tester/mock_drivers.go, internal/tester/driver_test.go, internal/usb/device.go</files>
<behavior>
- TestResult.CableType must be one of: CableTypeUSB, CableTypeDP, CableTypeHDMI
- MockUSBDriver.Read() returns TestResult{CableType: CableTypeUSB, USBVersion: "USB 3.2 Gen 2", SpeedGbps: 10.0, MaxWatts: 100, PinContinuity: true, HasEMarker: true, ResistanceOhm: 0.12}
- MockDPDriver.Read() returns TestResult{CableType: CableTypeDP, DPVersion: "1.4", PinContinuity: true}
- MockHDMIDriver.Read() returns TestResult{CableType: CableTypeHDMI, HDMIVersion: "2.1", PinContinuity: true}
- Read() before Connect() returns ErrNotConnected
- Disconnect() twice returns nil (idempotent)
- Compile-time interface assertion: `var _ TesterDriver = (*MockUSBDriver)(nil)` etc.
- KnownDevices in usb/device.go has entries for "dead0:0001" (TreedixUSB), "dead0:0002" (TreedixDP), "dead0:0003" (TreedixHDMI) — all Role: RoleCableTester
</behavior>
<action>
RED: Write driver_test.go first — compile-time assertions + behavior tests for all three mocks. Run: `go test ./internal/tester/... ./internal/usb/...` — must fail (package does not exist).
GREEN: Create internal/tester/driver.go:
```go
package tester
import "errors"
var ErrNotConnected = errors.New("tester: not connected — call Connect() first")
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 TesterDriver interface {
Connect() error
Read() (TestResult, error)
Disconnect() error
}
```
Create internal/tester/mock_drivers.go with MockUSBDriver, MockDPDriver, MockHDMIDriver structs. Each has a `connected bool` field. Connect() sets connected=true. Disconnect() sets connected=false (idempotent). Read() returns ErrNotConnected if !connected, else returns the deterministic TestResult shown in the behavior block.
Update internal/usb/device.go KnownDevices map: add three Treedix entries with placeholder VID:PIDs "dead0:0001", "dead0:0002", "dead0:0003", Role: RoleCableTester, BaudRate: 115200. Add comment: `// TODO(hardware): Replace placeholder VID:PIDs after characterization 2026-04-13`.
Commit RED then GREEN separately.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/tester/... ./internal/usb/... -v -count=1 -run "TestMock|TestKnown"</automated>
</verify>
<done>All mock discrete driver tests pass; KnownDevices has 4 entries (1 printer + 3 testers); go build ./... passes.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: FNB58 streaming mock driver</name>
<files>internal/tester/driver.go, internal/tester/mock_drivers.go, internal/tester/driver_test.go</files>
<behavior>
- LiveReading struct: Voltage float64, CurrentAmps float64, PowerWatts float64, PDProtocol string, Timestamp time.Time
- StreamingTesterDriver interface embeds TesterDriver and adds: Stream() <-chan LiveReading
- MockFNB58Driver.Stream() returns a channel that emits 3 deterministic LiveReading samples (V=5.1, A=3.0, W=15.3, PDProtocol="PD3.0") at 100ms intervals then closes
- Stream() before Connect() returns a closed channel (not nil, not blocking)
- Disconnect() closes the stream channel and sets connected=false
- Compile-time: `var _ StreamingTesterDriver = (*MockFNB58Driver)(nil)`
- KnownDevices has "dead0:0004" for FNB58 (Role: RoleCableTester, BaudRate: 0 = HID, not serial)
</behavior>
<action>
RED: Extend driver_test.go with FNB58-specific tests. Run tests — fail on missing types.
GREEN: Add to internal/tester/driver.go:
```go
type LiveReading struct {
Voltage float64
CurrentAmps float64
PowerWatts float64
PDProtocol string
Timestamp time.Time
}
type StreamingTesterDriver interface {
TesterDriver
Stream() <-chan LiveReading
}
```
Add MockFNB58Driver to mock_drivers.go. Connect() sets connected=true, initialises internal streamCh (buffered 8). Disconnect() closes streamCh if open, sets connected=false. Stream() returns streamCh (or a pre-closed channel if !connected). In Connect(), launch a goroutine that sends 3 readings 100ms apart then closes the channel — use a context derived from a cancelFunc stored on the struct so Disconnect() can also stop it early.
Add "dead0:0004" to KnownDevices: {VID:"dead0", PID:"0004", Name:"FNIRSI FNB58", Role:RoleCableTester, BaudRate:0} with `// TODO(hardware): BaudRate 0 = HID protocol — replace VID:PID after characterization`.
Commit RED then GREEN.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/tester/... -v -count=1 -run "TestFNB58|TestStream" -timeout 30s</automated>
</verify>
<done>FNB58 streaming tests pass; Stream() emits 3 readings and closes; Disconnect() stops stream; go build ./... passes; race detector clean (add -race flag).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| USB serial → tester package | Raw bytes from hardware (not applicable for mocks; real drivers must validate) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-01 | Tampering | TestResult deserialization (future real drivers) | accept | Mock phase only — add input validation when real drivers are implemented |
| T-05-02 | DoS | Stream() channel never closed | mitigate | MockFNB58Driver closes channel after N readings; Disconnect() closes channel; goroutine cancelled via context |
</threat_model>
<verification>
```
go build ./... # must pass
go test ./internal/tester/... -race -count=1 # all pass, race-clean
go test ./internal/usb/... -count=1 # existing tests still pass
grep -c "dead0:" internal/usb/device.go # must print 4
```
</verification>
<success_criteria>
- internal/tester package exists and compiles
- TesterDriver and StreamingTesterDriver interfaces defined
- MockUSBDriver, MockDPDriver, MockHDMIDriver: deterministic Read(), correct CableType
- MockFNB58Driver: Stream() emits readings, closes on Disconnect(), race-clean
- KnownDevices has 4 placeholder entries (3 Treedix + 1 FNB58), all Role: RoleCableTester
- All existing tests (phases 1-4) continue to pass
</success_criteria>
<output>
After completion, create `.planning/phases/05-cable-test-integration/05-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,339 @@
---
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()"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<interfaces>
<!-- Contracts this plan depends on, extracted from existing code. -->
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
```
</interfaces>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: NetBox CreateCable method + CableRecord type</name>
<files>internal/netbox/client.go, internal/netbox/types.go, internal/netbox/client_test.go</files>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -run TestCreateCable -v -count=1</automated>
</verify>
<done>CreateCable unit tests pass; go build ./... passes; integration test file exists with build tag (not run in CI).</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: TestHandler (3 endpoints) + router wiring + main.go init</name>
<files>internal/api/handlers/test.go, internal/api/handlers/test_test.go, internal/api/router.go, cmd/hwlab/main.go</files>
<behavior>
- 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
</behavior>
<action>
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).
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestTestHandler -v -count=1 -race</automated>
</verify>
<done>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).</done>
</task>
</tasks>
<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>
<verification>
```
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 .
```
</verification>
<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>
<output>
After completion, create `.planning/phases/05-cable-test-integration/05-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,250 @@
---
phase: 05-cable-test-integration
plan: "03"
type: execute
wave: 3
depends_on: ["05-02"]
files_modified:
- web/src/pages/CableTestPage.tsx
- web/src/pages/CableTestPage.test.tsx
- web/src/api/test.ts
- web/src/router.tsx
autonomous: true
requirements: [CBL-06]
must_haves:
truths:
- "Navigating to /test renders the Cable Test Station page"
- "Left panel shows active tester readout (or 'No tester connected' placeholder)"
- "Right panel shows last 20 tests in a scrollable list"
- "Center panel shows label preview for the most recent test"
- "Print & Next button POSTs the current test result and clears the form for the next cable"
- "Live FNB58 data streams into the tester readout panel via GET /api/test/events SSE"
- "Page is usable on mobile screen (single column stacked layout below lg breakpoint)"
artifacts:
- path: "web/src/pages/CableTestPage.tsx"
provides: "Cable Test Station page with three panels"
- path: "web/src/api/test.ts"
provides: "submitCableTest(), streamTestEvents(), getRecentTests() API helpers"
- path: "web/src/router.tsx"
provides: "/test route registered"
key_links:
- from: "Print & Next button"
to: "POST /api/test/cable"
via: "submitCableTest() mutation (TanStack Query useMutation)"
- from: "Live readout panel"
to: "GET /api/test/events"
via: "EventSource in useEffect, closed on unmount"
- from: "Recent tests panel"
to: "GET /api/test/recent"
via: "useQuery with 5s refetch interval"
---
<objective>
Build the Cable Test Station page at /test: three-panel layout (tester readout, label preview, recent tests), Print & Next workflow, live SSE updates, mobile-responsive.
Purpose: Operator can test cables and print labels from a single page without leaving the workflow.
Output: web/src/pages/CableTestPage.tsx, web/src/api/test.ts, router update.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-cable-test-integration/05-CONTEXT.md
@.planning/phases/05-cable-test-integration/05-02-SUMMARY.md
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
@web/src/router.tsx
@web/src/pages/DashboardPage.tsx
</context>
<interfaces>
<!-- Backend API contracts this page consumes. -->
POST /api/test/cable
Request body:
```json
{
"cable_type": 0,
"usb_version": "USB 3.2 Gen 2",
"dp_version": "",
"hdmi_version": "",
"speed_gbps": 10.0,
"max_watts": 100,
"pin_continuity": true,
"has_emarker": true,
"resistance_ohm": 0.12,
"hw_id": "HW-00042"
}
```
Response 201:
```json
{ "hw_id": "HW-00042", "netbox_id": 7, "print_skipped": false }
```
GET /api/test/recent
Response 200: array of TestResult objects (same shape as POST body)
GET /api/test/events (SSE)
event stream: `data: {"voltage":5.1,"current_amps":3.0,"power_watts":15.3,"pd_protocol":"PD3.0","timestamp":"..."}\n\n`
Design system tokens (from CLAUDE.md / ClickHouse design):
- Background: #000000
- Accent: #faff69 (neon volt)
- Card background: #111111
- Border: #222222
- Text primary: #ffffff
- Text muted: #888888
- Tailwind classes: bg-black, text-[#faff69], bg-[#111], border-[#222]
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: API helpers + Cable Test Station page</name>
<files>web/src/api/test.ts, web/src/pages/CableTestPage.tsx, web/src/router.tsx</files>
<action>
Create web/src/api/test.ts:
```typescript
export interface TestResult {
cable_type: 0 | 1 | 2; // 0=USB, 1=DP, 2=HDMI
usb_version: string;
dp_version: string;
hdmi_version: string;
speed_gbps: number;
max_watts: number;
pin_continuity: boolean;
has_emarker: boolean;
resistance_ohm: number;
hw_id: string;
}
export interface SubmitResponse {
hw_id: string;
netbox_id: number;
print_skipped: boolean;
}
export interface LiveReading {
voltage: number;
current_amps: number;
power_watts: number;
pd_protocol: string;
timestamp: string;
}
export async function submitCableTest(result: TestResult): Promise<SubmitResponse> {
const res = await fetch('/api/test/cable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (!res.ok) throw new Error(`submit failed: ${res.status}`);
return res.json();
}
export async function getRecentTests(): Promise<TestResult[]> {
const res = await fetch('/api/test/recent');
if (!res.ok) throw new Error(`recent tests failed: ${res.status}`);
return res.json();
}
```
Create web/src/pages/CableTestPage.tsx:
Layout: Three-column grid on lg+, single stacked column on mobile.
- Column 1 (tester readout): "Cable Test Station" heading; if liveReading state is set show Voltage/Current/Power/Protocol values with neon volt accent; else show "No tester connected" in muted text. Use EventSource in useEffect — close on component unmount. On each SSE message parse JSON into liveReading state.
- Column 2 (center — label preview + print): Show a card with current test fields as text (HW ID, type, version, speed, power, continuity, eMarker, resistance). "Print & Next" button (bg-[#faff69] text-black font-bold): calls submitCableTest mutation; on success show toast "Label printed for {hw_id}" (react-hot-toast) then reset form. If print_skipped=true toast says "Saved — printer not available". Loading state: button disabled + spinner.
- Column 3 (recent tests): "Recent Tests" heading; useQuery fetching getRecentTests() every 5000ms; render as list rows showing cable_type icon (USB/DP/HDMI), hw_id, pin_continuity chip (green tick / red X), speed_gbps.
Use lucide-react icons: Usb, Monitor, Tv2 for cable types. CheckCircle2 and XCircle for continuity.
For the "current test" form: a simple controlled form with number/text inputs for each TestResult field. Pre-populate with mock data when liveReading arrives (voltage/current into the readout; the test fields remain manual until real driver parsing is implemented).
Register /test route in web/src/router.tsx. Add "Test" nav link in the site nav (alongside Dashboard / Intake / Scan).
Design tokens: all backgrounds bg-black, cards bg-[#111] border border-[#222] rounded-lg p-4, headings text-white, muted text-[#888], accent text-[#faff69].
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -20</automated>
</verify>
<done>npm run build exits 0; /test route exists in router; CableTestPage renders three panels; Print & Next button is present.</done>
</task>
<task type="auto">
<name>Task 2: Unit tests for CableTestPage</name>
<files>web/src/pages/CableTestPage.test.tsx</files>
<action>
Create web/src/pages/CableTestPage.test.tsx using Vitest + React Testing Library (already in the project from Phase 3).
Mock fetch globally with vi.stubGlobal.
Tests:
1. "renders 'No tester connected' when no SSE data" — render CableTestPage, assert text present.
2. "renders recent tests list" — mock getRecentTests() returning 2 USB items; assert two rows visible.
3. "Print & Next calls submitCableTest and shows toast" — mock submitCableTest to resolve {hw_id:"HW-00001",netbox_id:1,print_skipped:false}; fill hw_id input; click Print & Next; await toast text "Label printed".
4. "Print & Next shows print_skipped toast" — mock returns print_skipped:true; assert toast says "Saved — printer not available".
Mock EventSource in test setup:
```typescript
class MockEventSource {
addEventListener = vi.fn();
removeEventListener = vi.fn();
close = vi.fn();
}
vi.stubGlobal('EventSource', MockEventSource);
```
Wrap component in necessary providers (QueryClientProvider, MemoryRouter, Toaster).
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm test -- --run CableTestPage 2>&1 | tail -30</automated>
</verify>
<done>All 4 CableTestPage tests pass; npm run build still exits 0.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → POST /api/test/cable | User-submitted form data; validated server-side in 05-02 |
| SSE stream → browser | Server push only; EventSource is read-only, no injection risk |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-07 | XSS | TestResult fields rendered in DOM | mitigate | Use React JSX (auto-escaped); no dangerouslySetInnerHTML |
| T-05-08 | DoS | EventSource leak on unmount | mitigate | useEffect cleanup closes EventSource; verified in test 1 |
</threat_model>
<verification>
```
cd web && npm run build # exits 0, no TypeScript errors
cd web && npm test -- --run CableTestPage # all 4 tests pass
# Manual smoke test: navigate to http://localhost:5173/test
# Verify three panels render, Print & Next button present, /test in nav
```
</verification>
<success_criteria>
- /test route renders Cable Test Station page with three panels
- Print & Next submits POST /api/test/cable, shows success toast, resets form
- SSE live readings update the tester readout panel; EventSource closed on unmount
- Recent tests list auto-refreshes every 5s
- Layout is single-column on mobile, three-column on lg+
- All 4 unit tests pass; npm run build exits 0
- ClickHouse design tokens applied throughout (#000000 background, #faff69 accent)
</success_criteria>
<output>
After completion, create `.planning/phases/05-cable-test-integration/05-03-SUMMARY.md`
</output>