package handlers import ( "context" "encoding/json" "testing" "switchboard-core/database" "switchboard-core/store" postgres "switchboard-core/store/postgres" sqlite "switchboard-core/store/sqlite" ) // Minimal static spec for testing — valid OpenAPI 3.0 structure. var testStaticSpec = []byte(` openapi: "3.0.3" info: title: Switchboard Core version: "${VERSION}" paths: /api/v1/health: get: summary: Health check responses: "200": description: OK tags: - name: system description: System endpoints `) func openapiTestStores(t *testing.T) store.Stores { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) if database.IsSQLite() { return sqlite.NewStores(database.TestDB) } return postgres.NewStores(database.TestDB) } // ── Test: Zero extensions → kernel spec only ────────────────── func TestBuildOpenAPISpec_NoExtensions(t *testing.T) { stores := openapiTestStores(t) spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2") if err != nil { t.Fatalf("BuildOpenAPISpec failed: %v", err) } // Version should be substituted info, _ := spec["info"].(map[string]any) if info == nil { t.Fatal("missing info field") } if v, _ := info["version"].(string); v != "0.6.2" { t.Errorf("expected version 0.6.2, got %q", v) } // Paths should contain the kernel endpoint paths, _ := spec["paths"].(map[string]any) if paths == nil { t.Fatal("missing paths field") } if _, ok := paths["/api/v1/health"]; !ok { t.Error("expected /api/v1/health in paths") } // Tags should contain kernel tag tags, _ := spec["tags"].([]any) if len(tags) < 1 { t.Error("expected at least 1 tag") } } // ── Test: Extension with api_routes but no api_schema → stubs ──── func TestBuildOpenAPISpec_StubEntries(t *testing.T) { stores := openapiTestStores(t) // Install a test extension with api_routes manifest := map[string]any{ "api_routes": []any{ map[string]any{"method": "GET", "path": "/items"}, map[string]any{"method": "POST", "path": "/items"}, }, } manifestJSON, _ := json.Marshal(manifest) err := stores.Packages.Create(context.Background(), &store.PackageRegistration{ ID: "test-ext", Title: "Test Extension", Type: "extension", Version: "1.0.0", Tier: "starlark", Manifest: manifest, Enabled: true, Status: "active", Source: "extension", Scope: "global", }) _ = manifestJSON if err != nil { t.Fatalf("failed to create test package: %v", err) } spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2") if err != nil { t.Fatalf("BuildOpenAPISpec failed: %v", err) } paths, _ := spec["paths"].(map[string]any) // Check GET stub getPath, ok := paths["/s/test-ext/api/items"] if !ok { t.Fatal("expected /s/test-ext/api/items in paths") } pathItem, _ := getPath.(map[string]any) getOp, _ := pathItem["get"].(map[string]any) if getOp == nil { t.Fatal("expected GET operation") } summary, _ := getOp["summary"].(string) if summary != "test-ext: GET /items" { t.Errorf("unexpected stub summary: %q", summary) } // Check POST stub postOp, _ := pathItem["post"].(map[string]any) if postOp == nil { t.Fatal("expected POST operation") } // Check tag tags, _ := getOp["tags"].([]any) if len(tags) != 1 || tags[0] != "extensions/test-ext" { t.Errorf("unexpected tags: %v", tags) } } // ── Test: Extension with api_schema → rich entries ──────── func TestBuildOpenAPISpec_SchemaEntries(t *testing.T) { stores := openapiTestStores(t) manifest := map[string]any{ "api_routes": []any{ map[string]any{"method": "GET", "path": "/items"}, map[string]any{"method": "POST", "path": "/items"}, map[string]any{"method": "DELETE", "path": "/items/:id"}, }, "api_schema": []any{ map[string]any{ "path": "/items", "method": "GET", "summary": "List items", "params": map[string]any{ "limit": map[string]any{"type": "integer", "default": 50}, }, "response": map[string]any{ "type": "object", "example": map[string]any{"data": []any{}}, }, }, map[string]any{ "path": "/items", "method": "POST", "summary": "Create item", "body": map[string]any{ "name": map[string]any{"type": "string", "required": true}, "description": map[string]any{"type": "string"}, }, }, }, } err := stores.Packages.Create(context.Background(), &store.PackageRegistration{ ID: "rich-ext", Title: "Rich Extension", Type: "extension", Version: "1.0.0", Tier: "starlark", Manifest: manifest, Enabled: true, Status: "active", Source: "extension", Scope: "global", }) if err != nil { t.Fatalf("failed to create test package: %v", err) } spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2") if err != nil { t.Fatalf("BuildOpenAPISpec failed: %v", err) } paths, _ := spec["paths"].(map[string]any) // GET /items should have rich schema (summary = "List items") itemsPath, ok := paths["/s/rich-ext/api/items"] if !ok { t.Fatal("expected /s/rich-ext/api/items in paths") } pathItem, _ := itemsPath.(map[string]any) getOp, _ := pathItem["get"].(map[string]any) if getOp == nil { t.Fatal("expected GET operation") } if s, _ := getOp["summary"].(string); s != "List items" { t.Errorf("expected rich summary, got %q", s) } // Should have parameters params, _ := getOp["parameters"].([]any) if len(params) == 0 { t.Error("expected parameters from api_schema") } // POST /items should have requestBody postOp, _ := pathItem["post"].(map[string]any) if postOp == nil { t.Fatal("expected POST operation") } if _, ok := postOp["requestBody"]; !ok { t.Error("expected requestBody from api_schema body") } // DELETE /items/:id should be a stub (no api_schema for it) deletePath, ok := paths["/s/rich-ext/api/items/:id"] if !ok { t.Fatal("expected /s/rich-ext/api/items/:id in paths") } deleteItem, _ := deletePath.(map[string]any) deleteOp, _ := deleteItem["delete"].(map[string]any) if deleteOp == nil { t.Fatal("expected DELETE operation") } deleteSummary, _ := deleteOp["summary"].(string) if deleteSummary != "rich-ext: DELETE /items/:id" { t.Errorf("expected stub summary for undeclared route, got %q", deleteSummary) } } // ── Test: Multiple extensions → all appear ──────── func TestBuildOpenAPISpec_MultipleExtensions(t *testing.T) { stores := openapiTestStores(t) for _, ext := range []struct { id string title string route string }{ {"ext-a", "Extension A", "/status"}, {"ext-b", "Extension B", "/health"}, } { err := stores.Packages.Create(context.Background(), &store.PackageRegistration{ ID: ext.id, Title: ext.title, Type: "extension", Version: "1.0.0", Tier: "starlark", Manifest: map[string]any{ "api_routes": []any{ map[string]any{"method": "GET", "path": ext.route}, }, }, Enabled: true, Status: "active", Source: "extension", Scope: "global", }) if err != nil { t.Fatalf("failed to create %s: %v", ext.id, err) } } spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2") if err != nil { t.Fatalf("BuildOpenAPISpec failed: %v", err) } paths, _ := spec["paths"].(map[string]any) if _, ok := paths["/s/ext-a/api/status"]; !ok { t.Error("expected ext-a path") } if _, ok := paths["/s/ext-b/api/health"]; !ok { t.Error("expected ext-b path") } // Verify tags tags, _ := spec["tags"].([]any) tagNames := make(map[string]bool) for _, tag := range tags { if m, ok := tag.(map[string]any); ok { if n, ok := m["name"].(string); ok { tagNames[n] = true } } } if !tagNames["extensions/ext-a"] { t.Error("expected extensions/ext-a tag") } if !tagNames["extensions/ext-b"] { t.Error("expected extensions/ext-b tag") } } // ── Test: Malformed api_schema → stubs, no crash ──────── func TestBuildOpenAPISpec_MalformedSchema(t *testing.T) { stores := openapiTestStores(t) manifest := map[string]any{ "api_routes": []any{ map[string]any{"method": "GET", "path": "/data"}, }, "api_schema": []any{ "not-an-object", // malformed map[string]any{"path": "/data"}, // missing method map[string]any{"method": "GET"}, // missing path map[string]any{"path": "/data", "method": "POST"}, // valid but no matching route }, } err := stores.Packages.Create(context.Background(), &store.PackageRegistration{ ID: "bad-schema", Title: "Bad Schema", Type: "extension", Version: "1.0.0", Tier: "starlark", Manifest: manifest, Enabled: true, Status: "active", Source: "extension", Scope: "global", }) if err != nil { t.Fatalf("failed to create package: %v", err) } spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2") if err != nil { t.Fatalf("BuildOpenAPISpec should not error on malformed schema: %v", err) } paths, _ := spec["paths"].(map[string]any) // GET /data should still appear as a stub if _, ok := paths["/s/bad-schema/api/data"]; !ok { t.Error("expected stub entry for /data despite malformed api_schema") } } // ── Test: Disabled extension → excluded ──────── func TestBuildOpenAPISpec_DisabledExcluded(t *testing.T) { stores := openapiTestStores(t) err := stores.Packages.Create(context.Background(), &store.PackageRegistration{ ID: "disabled-ext", Title: "Disabled", Type: "extension", Version: "1.0.0", Tier: "starlark", Manifest: map[string]any{ "api_routes": []any{ map[string]any{"method": "GET", "path": "/secret"}, }, }, Enabled: false, Status: "active", Source: "extension", Scope: "global", }) if err != nil { t.Fatalf("failed to create package: %v", err) } spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2") if err != nil { t.Fatalf("BuildOpenAPISpec failed: %v", err) } paths, _ := spec["paths"].(map[string]any) if _, ok := paths["/s/disabled-ext/api/secret"]; ok { t.Error("disabled extension should not appear in spec") } } // ── Test: Required OpenAPI 3.0 fields present ──────── func TestBuildOpenAPISpec_RequiredFields(t *testing.T) { stores := openapiTestStores(t) spec, err := BuildOpenAPISpec(stores, testStaticSpec, "1.0.0") if err != nil { t.Fatalf("BuildOpenAPISpec failed: %v", err) } if _, ok := spec["openapi"]; !ok { t.Error("missing required 'openapi' field") } if _, ok := spec["info"]; !ok { t.Error("missing required 'info' field") } if _, ok := spec["paths"]; !ok { t.Error("missing required 'paths' field") } }