homelabby/.planning/phases/05-cable-test-integration/05-02-PLAN.md
2026-04-10 07:04:57 +00:00

14 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
05-cable-test-integration 02 tdd 2
05-01
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
true
CBL-05
CBL-06
CBL-07
truths artifacts key_links
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
path provides exports
internal/netbox/client.go CreateCable method
CreateCable
path provides exports
internal/netbox/types.go CableRecord type
CableRecord
path provides exports
internal/api/handlers/test.go TestHandler (POST /api/test/cable, GET /api/test/events, GET /api/test/recent)
TestHandler
path provides
internal/api/router.go Three new /api/test/* routes
from to via
POST /api/test/cable handler netbox.Client.CreateCable creates cable record with test_data JSON
from to via
POST /api/test/cable handler printer.PrinterDriver.Print auto-prints cable label after record creation (non-fatal)
from to via
GET /api/test/events handler tester.StreamingTesterDriver.Stream() 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.

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

<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>
After completion, create `.planning/phases/05-cable-test-integration/05-02-SUMMARY.md`