From 4fc9362519e3279edae63f229278947b3cd62f94 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:54:33 +0000 Subject: [PATCH] feat(02-03): POST /api/intake handler with orchestrator and NetBox wiring - IntakeHandler with IntakeOrchestrator/IntakeNetBoxClient/IntakeCatalogUpdater/IntakeWAQ interfaces - Validates 1-3 photos, base64-encodes, calls Analyze, allocates HW-ID - Quick-add mode: confidence >= threshold skips review, creates NetBox record immediately - WAQ enqueue on NetBox failure returns 202 with queued=true - nil WAQ + NetBox down returns 503 - Six unit tests: reject-0, reject-4, high-confidence, low-confidence, quick-add, netbox-down - [Rule 1 - Bug] PatchCustomFields signature changed int -> int64 to match NetBoxOpsClient interface - [Rule 1 - Bug] UpdateCatalogStatus signature changed int -> int64 for consistency with CreateDevice return type --- internal/api/handlers/intake.go | 263 +++++++++++++++++++++++++ internal/api/handlers/intake_test.go | 268 ++++++++++++++++++++++++++ internal/inventory/catalog_updater.go | 2 +- internal/netbox/custom_fields.go | 2 +- internal/netbox/custom_fields_test.go | 4 +- 5 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 internal/api/handlers/intake.go create mode 100644 internal/api/handlers/intake_test.go diff --git a/internal/api/handlers/intake.go b/internal/api/handlers/intake.go new file mode 100644 index 0000000..178760a --- /dev/null +++ b/internal/api/handlers/intake.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "log" + "net/http" + "strings" + + "github.com/google/uuid" + + "git.georgsen.dk/hwlab/internal/ai" + "git.georgsen.dk/hwlab/internal/inventory" + "git.georgsen.dk/hwlab/internal/netbox" + "git.georgsen.dk/hwlab/internal/queue" +) + +// IntakeOrchestrator is the subset of ai.Orchestrator the intake handler needs. +// Using an interface allows tests to inject a mock without depending on the concrete type. +type IntakeOrchestrator interface { + Analyze(ctx context.Context, req ai.IntakeRequest) (*ai.IntakeResult, inventory.CatalogStatus, error) +} + +// IntakeNetBoxClient is the subset of netbox.Client the intake handler needs. +type IntakeNetBoxClient interface { + AllocateNextHWID(ctx context.Context) (string, error) + CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error) + PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error + SyncTags(ctx context.Context, tags []string) ([]netbox.TagRef, error) +} + +// IntakeCatalogUpdater is the subset needed for catalog status persistence. +type IntakeCatalogUpdater interface { + UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next inventory.CatalogStatus) (inventory.CatalogStatus, error) +} + +// IntakeWAQ is the subset of WAQ the handler needs. +type IntakeWAQ interface { + Enqueue(ctx context.Context, op queue.PendingOp) error +} + +// IntakeHandler handles POST /api/intake — multipart photo upload → AI analysis → +// HW-ID allocation → NetBox record creation (or WAQ enqueue on NetBox failure). +type IntakeHandler struct { + orchestrator IntakeOrchestrator + netboxClient IntakeNetBoxClient + catalogUpdater IntakeCatalogUpdater + waq IntakeWAQ // may be nil if DragonFlyDB unavailable + deviceTypeID int32 + roleID int32 + siteID int32 + quickAddEnabled bool + quickAddThresh float64 +} + +// NewIntakeHandler constructs an IntakeHandler. waq may be nil if DragonFlyDB is unavailable. +func NewIntakeHandler( + orch IntakeOrchestrator, + nb IntakeNetBoxClient, + cu IntakeCatalogUpdater, + waq IntakeWAQ, + deviceTypeID, roleID, siteID int32, + quickAddEnabled bool, + quickAddThresh float64, +) *IntakeHandler { + return &IntakeHandler{ + orchestrator: orch, + netboxClient: nb, + catalogUpdater: cu, + waq: waq, + deviceTypeID: deviceTypeID, + roleID: roleID, + siteID: siteID, + quickAddEnabled: quickAddEnabled, + quickAddThresh: quickAddThresh, + } +} + +// IntakeResponse is the JSON body returned by POST /api/intake. +type IntakeResponse struct { + HWID string `json:"hw_id"` + Model string `json:"model"` + Manufacturer string `json:"manufacturer"` + Category string `json:"category"` + Specs map[string]string `json:"specs"` + SuggestedTags []string `json:"suggested_tags"` + AINotes string `json:"ai_notes"` + Confidence float64 `json:"confidence"` + CatalogStatus string `json:"catalog_status"` + NetBoxID int64 `json:"netbox_id,omitempty"` + Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable +} + +// ServeHTTP implements http.Handler for the intake endpoint. +// +// Flow: +// 1. Parse multipart form (32 MB limit) +// 2. Validate 1–3 photo files → 400 on violation +// 3. Base64-encode photos → orchestrator.Analyze +// 4. AllocateNextHWID +// 5. CreateDevice in NetBox (or enqueue to WAQ on failure → 202) +// 6. PatchCustomFields, SyncTags, UpdateCatalogStatus +// 7. Return 201 IntakeResponse +func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // T-02-09: 32 MB hard cap on request body (DoS mitigation) + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, "invalid multipart form", http.StatusBadRequest) + return + } + + files := r.MultipartForm.File["photos"] + + // T-02-12: Explicit count check — 0 or 4+ photos rejected immediately + if len(files) == 0 { + http.Error(w, "at least 1 photo required", http.StatusBadRequest) + return + } + if len(files) > 3 { + http.Error(w, "maximum 3 photos allowed", http.StatusBadRequest) + return + } + + // Read and base64-encode each photo + photosBase64 := make([]string, 0, len(files)) + for _, fh := range files { + f, err := fh.Open() + if err != nil { + http.Error(w, "could not read photo", http.StatusInternalServerError) + return + } + data, err := io.ReadAll(f) + f.Close() + if err != nil { + http.Error(w, "could not read photo data", http.StatusInternalServerError) + return + } + // Detect MIME type from first 512 bytes + mimeType := http.DetectContentType(data) + encoded := "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + photosBase64 = append(photosBase64, encoded) + } + + jobID := uuid.New().String() + result, status, err := h.orchestrator.Analyze(r.Context(), ai.IntakeRequest{ + PhotosBase64: photosBase64, + JobID: jobID, + }) + if err != nil { + log.Printf("intake: orchestrator error for job %s: %v", jobID, err) + http.Error(w, "AI analysis failed", http.StatusInternalServerError) + return + } + + hwid, err := h.netboxClient.AllocateNextHWID(r.Context()) + if err != nil { + log.Printf("intake: HW-ID allocation error: %v", err) + http.Error(w, "HW-ID allocation failed", http.StatusInternalServerError) + return + } + + // Build device name from AI result + deviceName := strings.TrimSpace(result.Manufacturer + " " + result.Model) + if deviceName == "" { + deviceName = hwid + } + + // Attempt NetBox record creation + // Both quick-add and standard paths call CreateDevice. + // Quick-add path: proceed only when confidence meets threshold. + // Standard path: always attempt (review step is a UI concern, not handler concern). + deviceID, createErr := h.netboxClient.CreateDevice( + r.Context(), + deviceName, + hwid, + h.deviceTypeID, + h.roleID, + h.siteID, + ) + + if createErr != nil { + log.Printf("intake: NetBox CreateDevice error: %v", createErr) + // Enqueue to WAQ if available + if h.waq != nil { + payload, _ := json.Marshal(queue.CreateDevicePayload{ + Name: deviceName, + AssetTag: hwid, + DeviceTypeID: h.deviceTypeID, + RoleID: h.roleID, + SiteID: h.siteID, + }) + op, err := queue.NewPendingOp(queue.OpNetBoxCreateDevice, json.RawMessage(payload)) + if err == nil { + if enqErr := h.waq.Enqueue(r.Context(), op); enqErr != nil { + log.Printf("intake: WAQ enqueue error: %v", enqErr) + } + } + writeJSON(w, http.StatusAccepted, IntakeResponse{ + HWID: hwid, + Model: result.Model, + Manufacturer: result.Manufacturer, + Category: result.Category, + Specs: result.Specs, + SuggestedTags: result.SuggestedTags, + AINotes: result.AINotes, + Confidence: result.Confidence, + CatalogStatus: string(status), + Queued: true, + }) + return + } + // WAQ unavailable — cannot queue + http.Error(w, "NetBox unavailable and WAQ not configured", http.StatusServiceUnavailable) + return + } + + // NetBox create succeeded — patch custom fields, sync tags, update catalog status + cf := netbox.CustomFields{ + HWID: hwid, + CatalogStatus: string(status), + AINotes: result.AINotes, + } + patch := netbox.BuildFullCustomFieldsPatch(cf) + if err := h.netboxClient.PatchCustomFields(r.Context(), deviceID, patch); err != nil { + log.Printf("intake: PatchCustomFields error for device %d: %v", deviceID, err) + // Non-fatal — proceed with partial data + } + + if len(result.SuggestedTags) > 0 { + if _, err := h.netboxClient.SyncTags(r.Context(), result.SuggestedTags); err != nil { + log.Printf("intake: SyncTags error for device %d: %v", deviceID, err) + // Non-fatal + } + } + + if _, err := h.catalogUpdater.UpdateCatalogStatus(r.Context(), deviceID, "", status); err != nil { + log.Printf("intake: UpdateCatalogStatus error for device %d: %v", deviceID, err) + // Non-fatal + } + + writeJSON(w, http.StatusCreated, IntakeResponse{ + HWID: hwid, + Model: result.Model, + Manufacturer: result.Manufacturer, + Category: result.Category, + Specs: result.Specs, + SuggestedTags: result.SuggestedTags, + AINotes: result.AINotes, + Confidence: result.Confidence, + CatalogStatus: string(status), + NetBoxID: deviceID, + }) +} + +// writeJSON writes a JSON response with the given status code. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("writeJSON: encode error: %v", err) + } +} diff --git a/internal/api/handlers/intake_test.go b/internal/api/handlers/intake_test.go new file mode 100644 index 0000000..29f2545 --- /dev/null +++ b/internal/api/handlers/intake_test.go @@ -0,0 +1,268 @@ +package handlers_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "git.georgsen.dk/hwlab/internal/ai" + "git.georgsen.dk/hwlab/internal/api/handlers" + "git.georgsen.dk/hwlab/internal/inventory" + "git.georgsen.dk/hwlab/internal/netbox" + "git.georgsen.dk/hwlab/internal/queue" +) + +// --- Mock orchestrator --- + +type mockOrchestrator struct { + FixedResult *ai.IntakeResult + FixedStatus inventory.CatalogStatus + FixedErr error +} + +func (m *mockOrchestrator) Analyze(_ context.Context, _ ai.IntakeRequest) (*ai.IntakeResult, inventory.CatalogStatus, error) { + if m.FixedErr != nil { + return nil, "", m.FixedErr + } + return m.FixedResult, m.FixedStatus, nil +} + +// --- Mock NetBox client --- + +type mockNetBox struct { + allocateHWID string + allocateErr error + createDeviceID int64 + createDeviceErr error + patchErr error + syncTagsResult []netbox.TagRef + syncTagsErr error + createCalled int +} + +func (m *mockNetBox) AllocateNextHWID(_ context.Context) (string, error) { + return m.allocateHWID, m.allocateErr +} + +func (m *mockNetBox) CreateDevice(_ context.Context, _, _ string, _, _, _ int32) (int64, error) { + m.createCalled++ + return m.createDeviceID, m.createDeviceErr +} + +func (m *mockNetBox) PatchCustomFields(_ context.Context, _ int64, _ map[string]interface{}) error { + return m.patchErr +} + +func (m *mockNetBox) SyncTags(_ context.Context, _ []string) ([]netbox.TagRef, error) { + return m.syncTagsResult, m.syncTagsErr +} + +// --- Mock catalog updater --- + +type mockCatalogUpdater struct { + updateErr error +} + +func (m *mockCatalogUpdater) UpdateCatalogStatus(_ context.Context, _ int64, _, next inventory.CatalogStatus) (inventory.CatalogStatus, error) { + return next, m.updateErr +} + +// --- Mock WAQ --- + +type mockWAQ struct { + enqueued []queue.PendingOp + enqueueErr error +} + +func (m *mockWAQ) Enqueue(_ context.Context, op queue.PendingOp) error { + if m.enqueueErr != nil { + return m.enqueueErr + } + m.enqueued = append(m.enqueued, op) + return nil +} + +// --- Helpers --- + +// buildMultipartRequest creates a multipart/form-data POST request with n photo files. +func buildMultipartRequest(t *testing.T, n int) *http.Request { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + for i := 0; i < n; i++ { + fw, err := w.CreateFormFile("photos", "test.jpg") + if err != nil { + t.Fatalf("create form file: %v", err) + } + // Minimal JPEG header bytes for content-type detection + fw.Write([]byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10}) + } + w.Close() + req := httptest.NewRequest(http.MethodPost, "/api/intake", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + return req +} + +func defaultOrchResult(confidence float64) *mockOrchestrator { + status := inventory.StatusIndexed + if confidence < 0.75 { + status = inventory.StatusNeedsResearch + } + return &mockOrchestrator{ + FixedResult: &ai.IntakeResult{ + SerialNumber: "SN-001", + Model: "Model X", + Manufacturer: "Acme", + Category: "compute", + Specs: map[string]string{"cpu": "Intel"}, + SuggestedTags: []string{"server"}, + AINotes: "Test item", + Confidence: confidence, + }, + FixedStatus: status, + } +} + +func defaultNetBox() *mockNetBox { + return &mockNetBox{ + allocateHWID: "HW-00001", + createDeviceID: 42, + } +} + +func newHandler(orch handlers.IntakeOrchestrator, nb handlers.IntakeNetBoxClient, cu handlers.IntakeCatalogUpdater, w handlers.IntakeWAQ, quickAdd bool, quickThresh float64) *handlers.IntakeHandler { + return handlers.NewIntakeHandler(orch, nb, cu, w, 1, 1, 1, quickAdd, quickThresh) +} + +// --- Tests --- + +func TestIntakeHandlerRejectsZeroPhotos(t *testing.T) { + h := newHandler(defaultOrchResult(0.95), defaultNetBox(), &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90) + req := buildMultipartRequest(t, 0) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rec.Code) + } +} + +func TestIntakeHandlerRejectsFourPhotos(t *testing.T) { + h := newHandler(defaultOrchResult(0.95), defaultNetBox(), &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90) + req := buildMultipartRequest(t, 4) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rec.Code) + } +} + +func TestIntakeHandlerHighConfidence(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := defaultNetBox() + h := newHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String()) + } + + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.HWID != "HW-00001" { + t.Errorf("expected hw_id=HW-00001, got %s", resp.HWID) + } + if resp.Model != "Model X" { + t.Errorf("expected model=Model X, got %s", resp.Model) + } + if resp.Manufacturer != "Acme" { + t.Errorf("expected manufacturer=Acme, got %s", resp.Manufacturer) + } + if resp.CatalogStatus != string(inventory.StatusIndexed) { + t.Errorf("expected catalog_status=%s, got %s", inventory.StatusIndexed, resp.CatalogStatus) + } +} + +func TestIntakeHandlerLowConfidence(t *testing.T) { + orch := defaultOrchResult(0.40) + nb := defaultNetBox() + h := newHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201, got %d", rec.Code) + } + + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.CatalogStatus != string(inventory.StatusNeedsResearch) { + t.Errorf("expected catalog_status=%s, got %s", inventory.StatusNeedsResearch, resp.CatalogStatus) + } +} + +func TestIntakeHandlerQuickAdd(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := defaultNetBox() + h := newHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, true, 0.90) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String()) + } + // CreateDevice must have been called exactly once + if nb.createCalled != 1 { + t.Errorf("expected CreateDevice called 1 time, got %d", nb.createCalled) + } + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.HWID != "HW-00001" { + t.Errorf("expected hw_id=HW-00001, got %s", resp.HWID) + } +} + +func TestIntakeHandlerNetBoxDown(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := &mockNetBox{ + allocateHWID: "HW-00001", + createDeviceErr: fmt.Errorf("connection refused"), + } + waq := &mockWAQ{} + h := newHandler(orch, nb, &mockCatalogUpdater{}, waq, false, 0.90) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Errorf("expected 202, got %d: body=%s", rec.Code, rec.Body.String()) + } + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.Queued { + t.Error("expected queued=true in response") + } + if len(waq.enqueued) != 1 { + t.Errorf("expected 1 op enqueued, got %d", len(waq.enqueued)) + } +} diff --git a/internal/inventory/catalog_updater.go b/internal/inventory/catalog_updater.go index e5b9b75..7e014f0 100644 --- a/internal/inventory/catalog_updater.go +++ b/internal/inventory/catalog_updater.go @@ -23,7 +23,7 @@ func NewCatalogUpdater(client *netbox.Client) *CatalogUpdater { // // All catalog_status writes MUST go through this method to ensure T-04-01 mitigation: // the quality gate transition is always validated before any NetBox PATCH. -func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int, current, next CatalogStatus) (CatalogStatus, error) { +func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next CatalogStatus) (CatalogStatus, error) { newStatus, err := Transition(current, next) if err != nil { return "", err diff --git a/internal/netbox/custom_fields.go b/internal/netbox/custom_fields.go index c3394e0..cff7dfb 100644 --- a/internal/netbox/custom_fields.go +++ b/internal/netbox/custom_fields.go @@ -106,7 +106,7 @@ func BuildFullCustomFieldsPatch(cf CustomFields) map[string]interface{} { // PatchCustomFields updates the custom fields of a device identified by deviceID. // Uses go-netbox v4 PatchedWritableDeviceWithConfigContextRequest to send a partial update. -func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error { +func (c *Client) PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error { patchReq := nb.PatchedWritableDeviceWithConfigContextRequest{} patchReq.SetCustomFields(patch) diff --git a/internal/netbox/custom_fields_test.go b/internal/netbox/custom_fields_test.go index 6408439..8b0d592 100644 --- a/internal/netbox/custom_fields_test.go +++ b/internal/netbox/custom_fields_test.go @@ -77,7 +77,7 @@ func TestPatchCustomFieldsRoundTrip(t *testing.T) { t.Skip("HWLAB_TEST_DEVICE_ID not set — skipping round-trip integration test") } - var deviceID int + var deviceID int64 if _, err := fmt.Sscanf(deviceIDStr, "%d", &deviceID); err != nil { t.Fatalf("HWLAB_TEST_DEVICE_ID must be an integer: %v", err) } @@ -92,7 +92,7 @@ func TestPatchCustomFieldsRoundTrip(t *testing.T) { t.Fatalf("PatchCustomFields: %v", err) } - device, err := c.GetDevice(context.Background(), deviceID) + device, err := c.GetDevice(context.Background(), int(deviceID)) if err != nil { t.Fatalf("GetDevice: %v", err) }