- api/openapi.yaml converted 3.1 → 3.0.3 (oapi-codegen/kin-openapi supports 3.0; nullable syntax + example keywords), still redocly-clean - oapi-codegen (v2.4.1, strict server + chi) generates internal/httpapi/gen from the spec; `make generate` wired - internal/httpapi: chi router, /healthz (unauthenticated, SG18), RFC 9457 problem+json mapping from domain sentinels (SG11), 5xx detail logged server-side only, request logging with request IDs, graceful shutdown (SG4), interim static bearer auth (constant-time; dev-open when no token; OIDC JWT still to come in Phase 2) - Implemented: listEntities (type filter walks the hierarchy, keyset pagination), getEntity (UUID or slug, ETag), getEntityRelations, getBlastRadius, getGraph (nodes+edges for UIs), getOntology, listSignals, getFleetHealth, exportSeeds. Remaining 38 ops return 501 problem+json stubs (compiler-enforced interface completeness) - `oikos api` role live: migrate-on-start, serves :8090 - 15 API integration tests (auth, pagination, hierarchy filter, ETag, 404/501 problem shapes, graph, export) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
8.2 KiB
Go
293 lines
8.2 KiB
Go
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)
|
|
}
|
|
})
|
|
|
|
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(pool, cfg)
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|