--- phase: 03-dashboard-intake-ui plan: "02" type: execute wave: 1 depends_on: [] files_modified: - internal/api/handlers/inventory.go - internal/api/handlers/inventory_test.go - internal/api/router.go autonomous: true requirements: [UI-01, UI-04] must_haves: truths: - "GET /api/inventory returns a JSON array of inventory items with hw_id, name, catalog_status, and specs" - "GET /api/inventory/:id returns a single item by NetBox device ID including all custom fields" - "Both endpoints return proper HTTP status codes (200 on success, 404 for missing item, 502 on NetBox error)" - "Unit tests pass without a live NetBox connection" artifacts: - path: "internal/api/handlers/inventory.go" provides: "InventoryHandler with ListInventory and GetInventoryItem methods" exports: ["InventoryHandler", "NewInventoryHandler", "InventoryNetBoxClient"] - path: "internal/api/handlers/inventory_test.go" provides: "Unit tests for list and detail endpoints with mock NetBox client" - path: "internal/api/router.go" provides: "GET /api/inventory and GET /api/inventory/:id routes registered" key_links: - from: "internal/api/handlers/inventory.go" to: "internal/netbox.Client" via: "InventoryNetBoxClient interface (ListDevices + GetDevice)" - from: "internal/api/router.go" to: "internal/api/handlers.InventoryHandler" via: "r.Get('/inventory', ...) and r.Get('/inventory/{id}', ...)" --- Add GET /api/inventory and GET /api/inventory/:id endpoints to the Go backend so the React dashboard can fetch real inventory data. Purpose: The dashboard view (Plan 03) depends on these endpoints. They must exist and be testable before the frontend wires up TanStack Query hooks. Output: Two handler methods behind an interface, registered on the chi router, with unit tests. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md @.planning/phases/02-ai-pipeline/02-03-SUMMARY.md @internal/api/router.go @internal/netbox/client.go @internal/api/handlers/intake.go ```go // ListDevices returns up to limit devices from NetBox. func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error) // GetDevice retrieves a single device by its NetBox internal ID. func (c *Client) GetDevice(ctx context.Context, id int) (*Device, error) ``` ```go type Device struct { ID int Name string AssetTag string // HW-XXXXX id CustomFields CustomFields } type CustomFields struct { HWID string CatalogStatus string ProductURL string FirmwareVersion string TestDate string TestData string AINotes string PhotoURLs []string } ``` ```go func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler ``` // Define a narrow interface (InventoryNetBoxClient) rather than taking *netbox.Client directly. // This allows complete unit testing without a real NetBox. Task 1: InventoryHandler — list and detail endpoints with interface + unit tests internal/api/handlers/inventory.go, internal/api/handlers/inventory_test.go - TestListInventoryEmpty: mock returns [], handler returns 200 with JSON `[]` - TestListInventoryItems: mock returns 2 devices, handler returns 200 with JSON array of 2 items containing hw_id, name, catalog_status, asset_tag, photo_urls - TestListInventoryNetBoxError: mock returns error, handler returns 502 with {"error": "..."} - TestGetInventoryItemFound: mock returns device with id=42, GET /api/inventory/42 returns 200 with device JSON - TestGetInventoryItemNotFound: mock returns nil device + error containing "404", handler returns 404 - TestGetInventoryItemInvalidID: GET /api/inventory/abc returns 400 - TestGetInventoryItemNetBoxError: mock returns server error, handler returns 502 Read first: `internal/api/handlers/intake.go` (interface-for-testability pattern), `internal/netbox/types.go`, `internal/netbox/client.go`. **RED phase — write tests first:** Create `internal/api/handlers/inventory_test.go` with a `mockInventoryNetBoxClient` struct implementing `InventoryNetBoxClient`. Write all 7 tests listed in ``. Run `go test ./internal/api/handlers/... -run TestInventory` — tests must FAIL (handler does not exist yet). **GREEN phase — implement to pass tests:** Create `internal/api/handlers/inventory.go`: ```go package handlers import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "git.georgsen.dk/hwlab/internal/netbox" ) // InventoryNetBoxClient is the narrow interface the inventory handler needs. // *netbox.Client satisfies this interface. type InventoryNetBoxClient interface { ListDevices(ctx context.Context, limit int) ([]netbox.Device, error) GetDevice(ctx context.Context, id int) (*netbox.Device, error) } // InventoryHandler handles GET /api/inventory and GET /api/inventory/{id}. type InventoryHandler struct { nb InventoryNetBoxClient } // NewInventoryHandler creates an InventoryHandler. func NewInventoryHandler(nb InventoryNetBoxClient) *InventoryHandler { return &InventoryHandler{nb: nb} } // InventoryItemResponse is the JSON shape the frontend consumes. type InventoryItemResponse struct { ID int `json:"id"` Name string `json:"name"` AssetTag string `json:"asset_tag"` HWID string `json:"hw_id"` CatalogStatus string `json:"catalog_status"` ProductURL string `json:"product_url,omitempty"` Firmware string `json:"firmware_version,omitempty"` TestDate string `json:"test_date,omitempty"` TestData string `json:"test_data,omitempty"` AINotes string `json:"ai_notes,omitempty"` PhotoURLs []string `json:"photo_urls"` } func deviceToResponse(d netbox.Device) InventoryItemResponse { urls := d.CustomFields.PhotoURLs if urls == nil { urls = []string{} } return InventoryItemResponse{ ID: d.ID, Name: d.Name, AssetTag: d.AssetTag, HWID: d.CustomFields.HWID, CatalogStatus: d.CustomFields.CatalogStatus, ProductURL: d.CustomFields.ProductURL, Firmware: d.CustomFields.FirmwareVersion, TestDate: d.CustomFields.TestDate, TestData: d.CustomFields.TestData, AINotes: d.CustomFields.AINotes, PhotoURLs: urls, } } // ListInventory handles GET /api/inventory — returns up to 200 items. func (h *InventoryHandler) ListInventory(w http.ResponseWriter, r *http.Request) { devices, err := h.nb.ListDevices(r.Context(), 200) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)}) return } items := make([]InventoryItemResponse, 0, len(devices)) for _, d := range devices { items = append(items, deviceToResponse(d)) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(items) } // GetInventoryItem handles GET /api/inventory/{id}. func (h *InventoryHandler) GetInventoryItem(w http.ResponseWriter, r *http.Request) { rawID := chi.URLParam(r, "id") id, err := strconv.Atoi(rawID) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "id must be an integer"}) return } device, err := h.nb.GetDevice(r.Context(), id) if err != nil { w.Header().Set("Content-Type", "application/json") if strings.Contains(err.Error(), "404") || strings.Contains(strings.ToLower(err.Error()), "not found") { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]string{"error": "item not found"}) return } w.WriteHeader(http.StatusBadGateway) _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)}) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(deviceToResponse(*device)) } ``` Run `go test ./internal/api/handlers/... -run TestInventory` — all 7 tests must PASS. cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestInventory -v 2>&1 | tail -20 All 7 TestInventory* tests pass. `internal/api/handlers/inventory.go` exports `InventoryHandler`, `NewInventoryHandler`, `InventoryNetBoxClient`, `InventoryItemResponse`. `go build ./...` succeeds. Task 2: Wire inventory routes into chi router and main.go internal/api/router.go, cmd/hwlab/main.go Read first: `internal/api/router.go`, `cmd/hwlab/main.go`. **1. Update internal/api/router.go** — extend `NewRouter` to accept an inventory handler: Change signature from: ```go func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler ``` To: ```go func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler ``` Inside the `/api` route group, add: ```go r.Get("/inventory", inventoryHandler.ListInventory) r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) ``` Full updated `/api` block: ```go r.Route("/api", func(r chi.Router) { r.Get("/health", handlers.Health) r.Post("/intake", intakeHandler.ServeHTTP) r.Get("/inventory", inventoryHandler.ListInventory) r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) }) ``` **2. Update cmd/hwlab/main.go** — construct InventoryHandler and pass to NewRouter: After the existing netbox client construction (search for `netbox.NewClient`), add: ```go inventoryHandler := handlers.NewInventoryHandler(nbClient) ``` Update the `api.NewRouter(...)` call to include `inventoryHandler`: ```go router := api.NewRouter(webFS, intakeHandler, inventoryHandler) ``` Run `go build ./...` and `go test ./...` to confirm no regressions. cd /home/mikkel/homelabby && go build ./... 2>&1 && go test ./... 2>&1 | tail -15 `go build ./...` exits 0. `go test ./...` — all existing tests pass (integration tests skip gracefully). `grep "inventory" internal/api/router.go` shows both GET routes. `grep "inventoryHandler" cmd/hwlab/main.go` confirms construction and wiring. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Browser → GET /api/inventory | Untrusted caller requests inventory list — no auth in Phase 3 | | Browser → GET /api/inventory/:id | URL parameter id is untrusted input | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-04 | Tampering | GET /api/inventory/{id} URL param | mitigate | `strconv.Atoi` rejects non-integer IDs; returns 400 — no raw string reaches NetBox API call | | T-03-05 | Info Disclosure | GET /api/inventory error messages | accept | Error messages describe NetBox connectivity issues only; no credentials or internal paths exposed | | T-03-06 | DoS | GET /api/inventory limit | mitigate | Hard-coded limit=200 in ListDevices call; caller cannot request unbounded result sets | | T-03-07 | Elevation of Privilege | Inventory endpoints unauthenticated | accept | Phase 3 is homelab single-operator; no PII or critical data; Phase 4+ can add auth middleware if needed | From project root: 1. `go build ./...` — exits 0 2. `go test ./internal/api/handlers/... -run TestInventory -v` — 7 tests pass 3. `go test ./...` — all packages pass (integration tests skip) 4. `grep -n "inventory" internal/api/router.go` — shows both GET /inventory and GET /inventory/{id} 5. `grep "NewInventoryHandler" cmd/hwlab/main.go` — confirms wiring - GET /api/inventory returns JSON array using real NetBox ListDevices (integration test skips gracefully without live NetBox) - GET /api/inventory/:id returns single item or 404/502 with proper status codes - ID validation rejects non-integer inputs with 400 - 7 unit tests pass without live NetBox - go build ./... succeeds — no regressions to Phase 1/2 code After completion, create `.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md` following the summary template.