14 KiB
14 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-dashboard-intake-ui | 02 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.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)
<!-- From internal/netbox/types.go — Device shape the handler returns as JSON -->
```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
}
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 `<behavior>`. 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.
<threat_model>
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 |
| </threat_model> |
<success_criteria>
- 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 </success_criteria>