package httpapi // API integration tests. Guarded by OIKOS_TEST_DATABASE_URL (see // internal/db/integration_test.go); run via `make test-db-all` or plain // `go test` with the env var set. import ( "context" "encoding/json" "fmt" "math/rand" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" "github.com/jackc/pgx/v5" ) func newTestHandler(t *testing.T, cfg config.Config) http.Handler { t.Helper() baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL") if baseURL == "" { t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test") } ctx := context.Background() admin, err := pgx.Connect(ctx, baseURL) if err != nil { t.Fatalf("connect admin: %v", err) } dbName := fmt.Sprintf("oikos_api_test_%08x", rand.Int63()) if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil { admin.Close(ctx) t.Fatalf("create test db: %v", err) } admin.Close(ctx) qi := strings.Index(baseURL, "?") base, params := baseURL, "" if qi >= 0 { base, params = baseURL[:qi], baseURL[qi:] } testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params pool, err := db.New(ctx, testURL) if err != nil { t.Fatalf("connect test db: %v", err) } t.Cleanup(func() { pool.Close() admin, err := pgx.Connect(ctx, baseURL) if err == nil { admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)") admin.Close(ctx) } }) // Handler context governs the SSE listener goroutine. Cancel it before // the pool closes (cleanups run LIFO, so registering it after the // pool-close cleanup makes it run first) — otherwise the listener holds // a pooled connection and pool.Close() deadlocks. handlerCtx, cancelHandler := context.WithCancel(context.Background()) t.Cleanup(cancelHandler) if err := pool.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} { content, err := os.ReadFile("../../seeds/" + f) if err != nil { t.Fatalf("read seed %s: %v", f, err) } name := f err = pool.SeedIngest(ctx, name, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { var err error switch name { case "ontology.yaml": _, err = db.IngestOntologySeed(ctx, tx, data) case "inventory.yaml": _, err = db.IngestInventorySeed(ctx, tx, data) case "policy.yaml": _, err = db.IngestPolicySeed(ctx, tx, data) } return err }) if err != nil { t.Fatalf("ingest %s: %v", f, err) } } return NewHandler(handlerCtx, pool, cfg, nil) } func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) { t.Helper() req := httptest.NewRequest("GET", path, nil) for k, v := range headers { req.Header.Set(k, v) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var body map[string]any json.Unmarshal(rec.Body.Bytes(), &body) return rec, body } func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httptest.ResponseRecorder, map[string]any) { t.Helper() req := httptest.NewRequest("POST", path, strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var body map[string]any json.Unmarshal(rec.Body.Bytes(), &body) return rec, body } func devConfig() config.Config { c := config.Default() c.APIEnv = "dev" // no tokens → dev-open auth return c } func TestAPIEndToEnd(t *testing.T) { h := newTestHandler(t, devConfig()) t.Run("healthz", func(t *testing.T) { rec, body := get(t, h, "/healthz", nil) if rec.Code != 200 || body["status"] != "ok" { t.Fatalf("healthz = %d %v", rec.Code, body) } }) t.Run("list entities filtered by type", func(t *testing.T) { rec, body := get(t, h, "/api/v1/entities?type=service", nil) if rec.Code != 200 { t.Fatalf("status %d: %v", rec.Code, body) } items := body["items"].([]any) if len(items) < 20 { t.Errorf("expected 20+ services, got %d", len(items)) } }) t.Run("type filter includes descendants via hierarchy", func(t *testing.T) { // machine is abstract; proxmox-host/workstation/standalone-server descend from it rec, body := get(t, h, "/api/v1/entities?type=machine", nil) if rec.Code != 200 { t.Fatalf("status %d: %v", rec.Code, body) } items := body["items"].([]any) if len(items) < 5 { t.Errorf("expected 5+ machines (hubris, strong, vps, 2 workstations), got %d", len(items)) } }) t.Run("pagination", func(t *testing.T) { rec, body := get(t, h, "/api/v1/entities?limit=10", nil) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } if len(body["items"].([]any)) != 10 { t.Fatalf("limit=10 returned %d", len(body["items"].([]any))) } cursor, _ := body["next_cursor"].(string) if cursor == "" { t.Fatal("expected next_cursor") } rec2, body2 := get(t, h, "/api/v1/entities?limit=10&cursor="+cursor, nil) if rec2.Code != 200 { t.Fatalf("page 2 status %d", rec2.Code) } first := body2["items"].([]any)[0].(map[string]any)["slug"].(string) if first <= cursor { t.Errorf("page 2 first slug %q not after cursor %q", first, cursor) } }) t.Run("get entity by slug with etag", func(t *testing.T) { rec, body := get(t, h, "/api/v1/entities/host:hubris", nil) if rec.Code != 200 || body["slug"] != "host:hubris" { t.Fatalf("get by slug = %d %v", rec.Code, body["slug"]) } if rec.Header().Get("ETag") == "" { t.Error("missing ETag header") } // and by UUID id := body["id"].(string) rec2, body2 := get(t, h, "/api/v1/entities/"+id, nil) if rec2.Code != 200 || body2["slug"] != "host:hubris" { t.Errorf("get by uuid = %d %v", rec2.Code, body2["slug"]) } }) t.Run("unknown entity is 404 problem+json", func(t *testing.T) { rec, body := get(t, h, "/api/v1/entities/host:nonexistent", nil) if rec.Code != 404 { t.Fatalf("status %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" { t.Errorf("content-type %q", ct) } if body["title"] != "not found" { t.Errorf("problem title %v", body["title"]) } }) t.Run("relations", func(t *testing.T) { rec, body := get(t, h, "/api/v1/entities/host:hubris/relations?rel_type=hosts&direction=out", nil) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } items := body["items"].([]any) if len(items) < 10 { t.Errorf("hubris hosts %d guests, want 10+", len(items)) } }) t.Run("blast radius", func(t *testing.T) { rec, body := get(t, h, "/api/v1/entities/lxc:caddy/blast-radius?depth=2", nil) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } if len(body["items"].([]any)) < 2 { t.Errorf("blast radius too small: %v", body["items"]) } }) t.Run("graph", func(t *testing.T) { rec, body := get(t, h, "/api/v1/graph?root=service:paperless&depth=2", nil) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } if len(body["nodes"].([]any)) < 2 || len(body["edges"].([]any)) < 1 { t.Errorf("graph too small: %d nodes %d edges", len(body["nodes"].([]any)), len(body["edges"].([]any))) } }) t.Run("ontology", func(t *testing.T) { rec, body := get(t, h, "/api/v1/ontology", nil) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } if len(body["entity_types"].([]any)) != 59 { t.Errorf("entity_types = %d, want 59", len(body["entity_types"].([]any))) } if len(body["lifecycles"].([]any)) != 6 { t.Errorf("lifecycles = %d, want 6", len(body["lifecycles"].([]any))) } }) t.Run("signals empty list", func(t *testing.T) { rec, body := get(t, h, "/api/v1/signals", nil) if rec.Code != 200 { t.Fatalf("status %d: %v", rec.Code, body) } }) t.Run("export returns real seeds", func(t *testing.T) { rec, body := get(t, h, "/api/v1/export", nil) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } if len(body["inventory"].(string)) < 1000 { t.Errorf("inventory export suspiciously small") } }) t.Run("unimplemented endpoint is 501 problem+json", func(t *testing.T) { rec, body := get(t, h, "/api/v1/patterns", nil) if rec.Code != 501 { t.Fatalf("status %d, want 501", rec.Code) } if body["title"] != "not implemented" { t.Errorf("problem title %v", body["title"]) } }) } func TestAPIBearerAuth(t *testing.T) { cfg := devConfig() cfg.APIToken = "test-token-123" h := newTestHandler(t, cfg) // healthz stays open rec, _ := get(t, h, "/healthz", nil) if rec.Code != 200 { t.Errorf("healthz with auth enabled = %d, want 200", rec.Code) } // API requires the token rec, body := get(t, h, "/api/v1/entities", nil) if rec.Code != 401 { t.Errorf("no token = %d, want 401 (%v)", rec.Code, body) } rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer wrong"}) if rec.Code != 401 { t.Errorf("wrong token = %d, want 401", rec.Code) } rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer test-token-123"}) if rec.Code != 200 { t.Errorf("valid token = %d, want 200", rec.Code) } }