package inventoryapi import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/itworx/pulse/internal/auth" "github.com/itworx/pulse/internal/inventory" ) type stubRepository struct { items []inventory.EntitySummary detail inventory.EntityDetail filter inventory.EntityFilter err error } func (s *stubRepository) SearchEntities(_ context.Context, f inventory.EntityFilter) ([]inventory.EntitySummary, error) { s.filter = f return s.items, s.err } func (s *stubRepository) GetEntityDetail(_ context.Context, _ string) (inventory.EntityDetail, error) { return s.detail, s.err } func request(method, target string) *http.Request { r := httptest.NewRequest(method, target, nil) return r.WithContext(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer})) } func seeded() *stubRepository { at := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) item := inventory.EntitySummary{ID: "entity-a", EntityType: "container", CanonicalName: "api", DisplayName: "Manual API", Status: "attention", FirstSeenAt: &at, FactCount: 2, OverrideCount: 1, SourceCount: 2} return &stubRepository{items: []inventory.EntitySummary{item, {ID: "entity-b", DisplayName: "Next"}}, detail: inventory.EntityDetail{Entity: item, Aliases: []inventory.AliasView{}, Facts: []inventory.FactView{}, Overrides: []inventory.OverrideView{}, Effective: []inventory.EffectiveValue{}, Relations: []inventory.RelationView{{ID: "relation-a", PeerID: "entity-b", PeerName: "Database"}}}} } func TestListSupportsFiltersSortAndCursor(t *testing.T) { repo := seeded() res := httptest.NewRecorder() Handler{Repository: repo}.ServeHTTP(res, request(http.MethodGet, "/api/v1/entities?limit=1&q=api&type=container&status=attention&order=desc")) if res.Code != 200 || !strings.Contains(res.Body.String(), `"displayName":"Manual API"`) || !strings.Contains(res.Body.String(), `"hasMore":true`) { t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) } if repo.filter.Search != "api" || repo.filter.EntityType != "container" || repo.filter.Status != "attention" || repo.filter.Direction != "desc" { t.Fatalf("filter=%+v", repo.filter) } if !strings.Contains(res.Body.String(), `"nextCursor":"`) { t.Fatal("missing cursor") } } func TestListRejectsUnsafeBounds(t *testing.T) { for _, target := range []string{"/api/v1/entities?limit=101", "/api/v1/entities?order=random", "/api/v1/entities?after=bad!"} { res := httptest.NewRecorder() Handler{Repository: seeded()}.ServeHTTP(res, request(http.MethodGet, target)) if res.Code != 400 { t.Fatalf("%s status=%d", target, res.Code) } } } func TestDetailContainsEffectiveProvenanceAndRelations(t *testing.T) { repo := seeded() repo.detail.Effective = []inventory.EffectiveValue{{FieldName: "displayName", Value: []byte(`"Manual API"`), Origin: "override"}} res := httptest.NewRecorder() Handler{Repository: repo}.ServeHTTP(res, request(http.MethodGet, "/api/v1/entities/entity-a")) if res.Code != 200 || !strings.Contains(res.Body.String(), `"origin":"override"`) || !strings.Contains(res.Body.String(), `"relations":[`) { t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) } rel := httptest.NewRecorder() Handler{Repository: repo}.ServeHTTP(rel, request(http.MethodGet, "/api/v1/entities/entity-a/relations")) if rel.Code != 200 || !strings.Contains(rel.Body.String(), "Database") { t.Fatalf("relations=%s", rel.Body.String()) } } func TestErrorsAreSafeAndReadOnly(t *testing.T) { repo := seeded() repo.err = errors.New("database secret detail") res := httptest.NewRecorder() Handler{Repository: repo}.ServeHTTP(res, request(http.MethodGet, "/api/v1/entities")) if res.Code != 503 || strings.Contains(res.Body.String(), "secret") { t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) } repo.err = inventory.ErrNotFound res = httptest.NewRecorder() Handler{Repository: repo}.ServeHTTP(res, request(http.MethodGet, "/api/v1/entities/missing")) if res.Code != 404 { t.Fatalf("not found=%d", res.Code) } res = httptest.NewRecorder() Handler{Repository: seeded()}.ServeHTTP(res, request(http.MethodPost, "/api/v1/entities")) if res.Code != 405 { t.Fatalf("mutation=%d", res.Code) } } func TestAuthenticationAndUnavailableRepository(t *testing.T) { res := httptest.NewRecorder() Handler{Repository: seeded()}.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/v1/entities", nil)) if res.Code != 401 { t.Fatalf("anonymous=%d", res.Code) } res = httptest.NewRecorder() Handler{}.ServeHTTP(res, request(http.MethodGet, "/api/v1/entities")) if res.Code != 503 { t.Fatalf("nil repo=%d", res.Code) } }