From 1520125e874f179f89d1e57c883ac9b355470933 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 01:59:41 +0100 Subject: [PATCH 1/3] =?UTF-8?q?release:=20v0.8.0-alpha.1=20=E2=80=94=20mod?= =?UTF-8?q?ule=20migration,=20SDK-codegen=20surface,=20security=20hardenin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the v0.8.0-alpha.1 release work for core/api. Module path migration, new SDK-codegen interfaces, and significant security hardening across the request boundary. * Module path migration: forge.lthn.ai/core/api -> dappco.re/go/api. All sibling dappco.re/go/* deps pinned to v0.8.0-alpha.1; PHP-side dedoc/scramble bumped ^0.12 -> ^0.13. * SDK codegen surface (Go side): - Describable interface — providers expose human-readable metadata - Renderable interface — renderers expose templates and hints - StreamGroup interface — SSE endpoints declare event shapes - Cache-Control hints integrate with RouteGroup - IsValidMCPServerID validates server identifiers at the boundary * Security hardening: - SSRF guard at the doHTTPClientRequest choke point with allow-list and unit + integration coverage - PackageName hardened against flag-injection (G204 false-positive annotated) - schemaInt bound-checks uint/uint64 -> int coercion (G115) - newChatCompletionID G404 false-positive annotated - outbound request cookie path G124 false-positive annotated - API key auth lookup failure path hardened - .gitleaksignore added for one documented false-positive * PHP test infrastructure: - Pest testbench bootstrap signature corrected - Stable core module release pinned * AX-10 scaffold: tests/cli/api/ Taskfile drivers cover the CLI surface. * Repo hygiene: - removed previously-tracked .DS_Store under src/php/src/Website - dropped empty .core/TODO.md Refs: RFC-CORE-008-AGENT-EXPERIENCE.md (AX-1, AX-6, AX-10) Co-authored-by: Athena Co-authored-by: Cerberus Co-authored-by: Hephaestus Co-authored-by: Cladius Maximus --- .core/TODO.md | 0 .gitleaksignore | 10 + RFC.md | 58 +++++ api.go | 34 ++- api_describable_test.go | 233 ++++++++++++++++++ api_renderable_test.go | 179 ++++++++++++++ api_test.go | 2 +- authentik_integration_test.go | 2 +- authentik_test.go | 38 ++- authz_test.go | 2 +- bridge.go | 28 +++ bridge_internal_test.go | 74 ++++++ bridge_test.go | 48 +++- brotli_test.go | 2 +- cache_config_test.go | 62 +++++ cache_test.go | 30 ++- chat_completions.go | 82 +++--- chat_completions_internal_test.go | 42 +++- chat_completions_test.go | 2 +- client.go | 11 + client_test.go | 2 +- cmd/api/cmd.go | 2 +- cmd/api/cmd_args_test.go | 78 ++++++ cmd/api/cmd_sdk.go | 6 +- cmd/api/cmd_sdk_test.go | 65 ++++- cmd/api/cmd_spec.go | 4 +- cmd/api/cmd_spec_test.go | 39 ++- cmd/api/cmd_test.go | 35 +++ cmd/api/spec_builder.go | 2 +- cmd/api/spec_groups_iter.go | 2 +- codegen.go | 25 +- codegen_test.go | 73 +++++- composer.json | 3 +- export.go | 4 +- export_test.go | 2 +- expvar_test.go | 2 +- go.mod | 12 +- graphql_config_test.go | 2 +- graphql_test.go | 2 +- group.go | 58 +++++ group_test.go | 2 +- gzip_test.go | 2 +- httpsign_test.go | 2 +- i18n_test.go | 2 +- location_test.go | 2 +- middleware_test.go | 2 +- modernization_test.go | 105 +------- openapi.go | 201 ++++++++++++++- openapi_test.go | 2 +- pkg/provider/cache_control_example_test.go | 32 +++ pkg/provider/cache_control_test.go | 131 ++++++++++ pkg/provider/provider.go | 2 +- pkg/provider/proxy.go | 2 +- pkg/provider/proxy_test.go | 4 +- pkg/provider/registry.go | 2 +- pkg/provider/registry_test.go | 4 +- pkg/stream/stream_group.go | 186 ++++++++++++++ pkg/stream/stream_group_example_test.go | 35 +++ pkg/stream/stream_group_test.go | 223 +++++++++++++++++ pprof_test.go | 2 +- ratelimit_test.go | 2 +- response_test.go | 2 +- runtime_config_test.go | 98 ++++++++ secure_test.go | 2 +- sessions_test.go | 2 +- slog_test.go | 2 +- spec_builder_helper_test.go | 86 ++++++- spec_registry_test.go | 2 +- src/php/src/Api/Boot.php | 4 + .../src/Api/Controllers/McpApiController.php | 43 +++- src/php/src/Api/Jobs/DeliverWebhookJob.php | 9 +- .../src/Api/Middleware/AuthenticateApiKey.php | 31 ++- src/php/src/Api/Models/WebhookDelivery.php | 164 +++++++++--- src/php/src/Api/Models/WebhookEndpoint.php | 57 +++++ .../src/Api/RateLimit/RateLimitService.php | 6 + .../Tests/Feature/AuthenticateApiKeyTest.php | 25 ++ .../src/Api/Tests/Feature/McpResourceTest.php | 31 ++- .../Api/Tests/Feature/McpServerAccessTest.php | 10 +- .../Api/Tests/Feature/McpServerDetailTest.php | 10 +- .../src/Api/Tests/Feature/RateLimitTest.php | 10 +- .../Api/Tests/Feature/RateLimitingTest.php | 12 +- .../Api/Tests/Feature/WebhookDeliveryTest.php | 96 +++++--- src/php/src/Website/.DS_Store | Bin 6148 -> 0 bytes .../tests/Feature/ApiVersionParsingTest.php | 40 +++ sse_test.go | 2 +- ssrf_guard.go | 139 +++++++++++ ssrf_guard_internal_test.go | 175 +++++++++++++ static_test.go | 2 +- sunset_test.go | 2 +- swagger_test.go | 2 +- tests/Pest.php | 41 ++- tests/cli/api/Taskfile.yaml | 26 ++ timeout_test.go | 2 +- tracing_test.go | 2 +- transport_client.go | 11 + websocket_test.go | 2 +- 96 files changed, 3152 insertions(+), 298 deletions(-) delete mode 100644 .core/TODO.md create mode 100644 .gitleaksignore create mode 100644 RFC.md create mode 100644 api_describable_test.go create mode 100644 api_renderable_test.go create mode 100644 bridge_internal_test.go create mode 100644 cache_config_test.go create mode 100644 cmd/api/cmd_args_test.go create mode 100644 cmd/api/cmd_test.go create mode 100644 pkg/provider/cache_control_example_test.go create mode 100644 pkg/provider/cache_control_test.go create mode 100644 pkg/stream/stream_group.go create mode 100644 pkg/stream/stream_group_example_test.go create mode 100644 pkg/stream/stream_group_test.go create mode 100644 runtime_config_test.go delete mode 100644 src/php/src/Website/.DS_Store create mode 100644 ssrf_guard.go create mode 100644 ssrf_guard_internal_test.go create mode 100644 tests/cli/api/Taskfile.yaml diff --git a/.core/TODO.md b/.core/TODO.md deleted file mode 100644 index e69de29..0000000 diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..86f94ae --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,10 @@ +# gitleaks ignore — documented false positives +# +# Filed: Mantis #325. Reviewer: argus + athena. 2026-04-25. +# Format: ::: +# +# src/php/src/Api/Documentation/Examples/CommonExamples.php:169 — Authorization +# header example string 'hk_1234567890abcdefghijklmnop' shown in OpenAPI/SDK +# documentation; placeholder fixture, not a real secret. + +753812ad57c3c5aa0cd66880e26433d71bb79f86:src/php/src/Api/Documentation/Examples/CommonExamples.php:generic-api-key:169 diff --git a/RFC.md b/RFC.md new file mode 100644 index 0000000..3b65350 --- /dev/null +++ b/RFC.md @@ -0,0 +1,58 @@ +# API RFC Notes + +## Handler Metadata Example + +```go +type createWidgetHandler struct{} + +func (h *createWidgetHandler) Describe() api.RouteDescription { + return api.RouteDescription{ + StatusCode: http.StatusCreated, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }, + } +} + +func (h *createWidgetHandler) OperationID() string { return "widgets_create" } +func (h *createWidgetHandler) Tags() []string { return []string{"widgets"} } +func (h *createWidgetHandler) Summary() string { return "Create widget" } +func (h *createWidgetHandler) Description() string { return "Creates a widget." } + +func (h *createWidgetHandler) Render() api.RenderHints { + return api.RenderHints{ + Kind: "form", + Fields: []api.FieldHint{ + {Name: "name", Label: "Name", Type: "text", Required: true}, + }, + Actions: []api.ActionHint{ + {Name: "preview", Label: "Preview", Method: http.MethodGet}, + }, + } +} + +func (g *widgetsGroup) Describe() []api.RouteDescription { + handler := &createWidgetHandler{} + return []api.RouteDescription{ + { + Method: http.MethodPost, + Path: "/", + Handler: handler, + }, + } +} +``` + +When a `RouteDescription` carries a handler that implements `api.Describable` +and/or `api.Renderable`, `SpecBuilder` uses that metadata to populate the +OpenAPI `operationId`, `tags`, `summary`, `description`, and the +`x-render-hints` vendor extension. diff --git a/api.go b/api.go index 01e7f3f..b878da5 100644 --- a/api.go +++ b/api.go @@ -13,6 +13,7 @@ import ( "time" core "dappco.re/go/core" + apistream "dappco.re/go/api/pkg/stream" "github.com/gin-contrib/expvar" "github.com/gin-contrib/pprof" @@ -37,6 +38,7 @@ const shutdownTimeout = 10 * time.Second type Engine struct { addr string groups []RouteGroup + streamGroups []apistream.StreamGroup middlewares []gin.HandlerFunc chatCompletionsResolver *ModelResolver chatCompletionsPath string @@ -139,6 +141,18 @@ func (e *Engine) Register(group RouteGroup) { e.groups = append(e.groups, group) } +// RegisterStreamGroup adds a declarative SSE/WebSocket handler group to the engine. +// +// Example: +// +// engine.RegisterStreamGroup(stream.NewGroup("events")) +func (e *Engine) RegisterStreamGroup(group apistream.StreamGroup) { + if isNilStreamGroup(group) { + return + } + e.streamGroups = append(e.streamGroups, group) +} + // Channels returns all WebSocket channel names from registered StreamGroups. // Groups that do not implement StreamGroup are silently skipped. // @@ -268,6 +282,14 @@ func (e *Engine) build() *gin.Engine { g.RegisterRoutes(rg) } + // Mount each registered declarative stream group at the engine root. + for _, g := range e.streamGroups { + if isNilStreamGroup(g) { + continue + } + g.Register(r) + } + // Mount WebSocket handler if configured. WithWebSocket (gin-native) takes // precedence over WithWSHandler (http.Handler) when both are supplied so // the more specific gin form wins. @@ -320,11 +342,19 @@ func (e *Engine) build() *gin.Engine { } func isNilRouteGroup(group RouteGroup) bool { - if group == nil { + return isNilValue(group) +} + +func isNilStreamGroup(group apistream.StreamGroup) bool { + return isNilValue(group) +} + +func isNilValue(v any) bool { + if v == nil { return true } - value := reflect.ValueOf(group) + value := reflect.ValueOf(v) switch value.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: return value.IsNil() diff --git a/api_describable_test.go b/api_describable_test.go new file mode 100644 index 0000000..b97ed92 --- /dev/null +++ b/api_describable_test.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + api "dappco.re/go/api" +) + +type describableSpecGroup struct { + name string + basePath string + descs []api.RouteDescription +} + +func (g *describableSpecGroup) Name() string { return g.name } +func (g *describableSpecGroup) BasePath() string { return g.basePath } +func (g *describableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (g *describableSpecGroup) Describe() []api.RouteDescription { return g.descs } + +type describableHandler struct { + desc api.RouteDescription + operationID string + tags []string + summary string + longDescription string +} + +func (h *describableHandler) Describe() api.RouteDescription { + if h == nil { + return api.RouteDescription{} + } + return h.desc +} + +func (h *describableHandler) OperationID() string { + if h == nil { + return "" + } + return h.operationID +} + +func (h *describableHandler) Tags() []string { + if h == nil { + return nil + } + return h.tags +} + +func (h *describableHandler) Summary() string { + if h == nil { + return "" + } + return h.summary +} + +func (h *describableHandler) Description() string { + if h == nil { + return "" + } + return h.longDescription +} + +func buildDescribableOperation(t *testing.T, group api.RouteGroup, path, method string) map[string]any { + t.Helper() + + builder := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + data, err := builder.Build([]api.RouteGroup{group}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + pathItem, ok := paths[path].(map[string]any) + if !ok { + t.Fatalf("expected path %q in spec", path) + } + + operation, ok := pathItem[method].(map[string]any) + if !ok { + t.Fatalf("expected %s operation on %q", method, path) + } + + return operation +} + +func TestDescribable_Good_HandlerMetadataFlowsToOpenAPI(t *testing.T) { + handler := &describableHandler{ + desc: api.RouteDescription{ + StatusCode: http.StatusCreated, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }, + }, + operationID: "widgets_create", + tags: []string{"widgets", "catalog"}, + summary: "Create widget", + longDescription: "Creates a widget and returns the stored record.", + } + + group := &describableSpecGroup{ + name: "widgets", + basePath: "/api/widgets", + descs: []api.RouteDescription{ + { + Method: http.MethodPost, + Path: "/", + Handler: handler, + }, + }, + } + + operation := buildDescribableOperation(t, group, "/api/widgets", "post") + + if got := operation["operationId"]; got != "widgets_create" { + t.Fatalf("expected handler operationId, got %v", got) + } + if got := operation["summary"]; got != "Create widget" { + t.Fatalf("expected handler summary, got %v", got) + } + if got := operation["description"]; got != "Creates a widget and returns the stored record." { + t.Fatalf("expected handler description, got %v", got) + } + + tags, ok := operation["tags"].([]any) + if !ok { + t.Fatalf("expected tags array, got %T", operation["tags"]) + } + if len(tags) != 2 || tags[0] != "widgets" || tags[1] != "catalog" { + t.Fatalf("expected handler tags, got %v", tags) + } + + requestBody := operation["requestBody"].(map[string]any) + content := requestBody["content"].(map[string]any) + schema := content["application/json"].(map[string]any)["schema"].(map[string]any) + properties := schema["properties"].(map[string]any) + if _, ok := properties["name"]; !ok { + t.Fatal("expected request body schema from handler Describe") + } + + responses := operation["responses"].(map[string]any) + if _, ok := responses["201"]; !ok { + t.Fatal("expected status code from handler Describe") + } +} + +func TestDescribable_Bad_MissingHandlerMetadataFallsBackSafely(t *testing.T) { + group := &describableSpecGroup{ + name: "widgets", + basePath: "/api/widgets", + descs: []api.RouteDescription{ + { + Method: http.MethodGet, + Path: "/status", + Summary: "Widget status", + Description: "Returns widget availability.", + Tags: []string{"status"}, + Handler: &describableHandler{}, + }, + }, + } + + operation := buildDescribableOperation(t, group, "/api/widgets/status", "get") + + if got := operation["operationId"]; got != "get_api_widgets_status" { + t.Fatalf("expected generated operationId fallback, got %v", got) + } + if got := operation["summary"]; got != "Widget status" { + t.Fatalf("expected route summary fallback, got %v", got) + } + if got := operation["description"]; got != "Returns widget availability." { + t.Fatalf("expected route description fallback, got %v", got) + } + + tags, ok := operation["tags"].([]any) + if !ok { + t.Fatalf("expected tags array, got %T", operation["tags"]) + } + if len(tags) != 1 || tags[0] != "status" { + t.Fatalf("expected route tag fallback, got %v", tags) + } +} + +func TestDescribable_Ugly_NilHandlerIsIgnored(t *testing.T) { + group := &describableSpecGroup{ + name: "widgets", + basePath: "/api/widgets", + descs: []api.RouteDescription{ + { + Method: http.MethodGet, + Path: "/status", + Handler: (*describableHandler)(nil), + }, + }, + } + + operation := buildDescribableOperation(t, group, "/api/widgets/status", "get") + + if got := operation["operationId"]; got != "get_api_widgets_status" { + t.Fatalf("expected generated operationId with nil handler, got %v", got) + } + + tags, ok := operation["tags"].([]any) + if !ok { + t.Fatalf("expected tags array, got %T", operation["tags"]) + } + if len(tags) != 1 || tags[0] != "widgets" { + t.Fatalf("expected group-name tag fallback, got %v", tags) + } +} diff --git a/api_renderable_test.go b/api_renderable_test.go new file mode 100644 index 0000000..8f43998 --- /dev/null +++ b/api_renderable_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + api "dappco.re/go/api" +) + +type renderableSpecGroup struct { + name string + basePath string + descs []api.RouteDescription +} + +func (g *renderableSpecGroup) Name() string { return g.name } +func (g *renderableSpecGroup) BasePath() string { return g.basePath } +func (g *renderableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (g *renderableSpecGroup) Describe() []api.RouteDescription { return g.descs } + +type renderableHandler struct { + hints api.RenderHints +} + +func (h *renderableHandler) Render() api.RenderHints { + if h == nil { + return api.RenderHints{} + } + return h.hints +} + +func buildRenderableOperation(t *testing.T, group api.RouteGroup, path, method string) map[string]any { + t.Helper() + + builder := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + data, err := builder.Build([]api.RouteGroup{group}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + pathItem, ok := paths[path].(map[string]any) + if !ok { + t.Fatalf("expected path %q in spec", path) + } + + operation, ok := pathItem[method].(map[string]any) + if !ok { + t.Fatalf("expected %s operation on %q", method, path) + } + + return operation +} + +func TestRenderable_Good_HandlerHintsFlowToOpenAPI(t *testing.T) { + group := &renderableSpecGroup{ + name: "widgets", + basePath: "/api/widgets", + descs: []api.RouteDescription{ + { + Method: http.MethodPost, + Path: "/", + Handler: &renderableHandler{ + hints: api.RenderHints{ + Kind: "form", + Fields: []api.FieldHint{ + { + Name: "name", + Label: "Name", + Type: "text", + Required: true, + Validation: map[string]any{ + "minLength": 3, + }, + }, + }, + Actions: []api.ActionHint{ + { + Name: "preview", + Label: "Preview", + Method: http.MethodGet, + Variant: "secondary", + }, + }, + }, + }, + }, + }, + } + + operation := buildRenderableOperation(t, group, "/api/widgets", "post") + + rawHints, ok := operation["x-render-hints"].(map[string]any) + if !ok { + t.Fatalf("expected x-render-hints extension, got %T", operation["x-render-hints"]) + } + if got := rawHints["kind"]; got != "form" { + t.Fatalf("expected render kind form, got %v", got) + } + + fields, ok := rawHints["fields"].([]any) + if !ok || len(fields) != 1 { + t.Fatalf("expected one render field, got %v", rawHints["fields"]) + } + field := fields[0].(map[string]any) + if got := field["name"]; got != "name" { + t.Fatalf("expected render field name, got %v", got) + } + if got := field["required"]; got != true { + t.Fatalf("expected render field required=true, got %v", got) + } + validation := field["validation"].(map[string]any) + if got := validation["minLength"]; got != float64(3) { + t.Fatalf("expected validation minLength=3, got %v", got) + } + + actions, ok := rawHints["actions"].([]any) + if !ok || len(actions) != 1 { + t.Fatalf("expected one render action, got %v", rawHints["actions"]) + } + action := actions[0].(map[string]any) + if got := action["name"]; got != "preview" { + t.Fatalf("expected render action name, got %v", got) + } +} + +func TestRenderable_Bad_EmptyHintsAreOmittedSafely(t *testing.T) { + group := &renderableSpecGroup{ + name: "widgets", + basePath: "/api/widgets", + descs: []api.RouteDescription{ + { + Method: http.MethodGet, + Path: "/status", + Handler: &renderableHandler{}, + }, + }, + } + + operation := buildRenderableOperation(t, group, "/api/widgets/status", "get") + + if _, ok := operation["x-render-hints"]; ok { + t.Fatalf("expected empty render hints to be omitted, got %v", operation["x-render-hints"]) + } +} + +func TestRenderable_Ugly_NilHandlerIsIgnored(t *testing.T) { + group := &renderableSpecGroup{ + name: "widgets", + basePath: "/api/widgets", + descs: []api.RouteDescription{ + { + Method: http.MethodGet, + Path: "/status", + Handler: (*renderableHandler)(nil), + }, + }, + } + + operation := buildRenderableOperation(t, group, "/api/widgets/status", "get") + + if _, ok := operation["x-render-hints"]; ok { + t.Fatalf("expected nil renderable handler to be ignored, got %v", operation["x-render-hints"]) + } +} diff --git a/api_test.go b/api_test.go index 948d353..cc33b9a 100644 --- a/api_test.go +++ b/api_test.go @@ -13,7 +13,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Test helpers ──────────────────────────────────────────────────────── diff --git a/authentik_integration_test.go b/authentik_integration_test.go index 1aae05a..89fed49 100644 --- a/authentik_integration_test.go +++ b/authentik_integration_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - api "dappco.re/go/core/api" + api "dappco.re/go/api" "github.com/gin-gonic/gin" ) diff --git a/authentik_test.go b/authentik_test.go index b44b7c8..c5469be 100644 --- a/authentik_test.go +++ b/authentik_test.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── AuthentikUser ────────────────────────────────────────────────────── @@ -92,6 +92,42 @@ func TestAuthentikConfig_Good(t *testing.T) { } } +func TestAuthentikConfig_Ugly_BlankPublicPathsCollapseToNil(t *testing.T) { + e, err := api.New(api.WithAuthentik(api.AuthentikConfig{ + TrustedProxy: true, + PublicPaths: []string{" ", "\t", ""}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.AuthentikConfig() + if cfg.PublicPaths != nil { + t.Fatalf("expected nil public paths after normalisation, got %v", cfg.PublicPaths) + } +} + +func TestAuthentikConfig_Ugly_RootPublicPathIsPreserved(t *testing.T) { + e, err := api.New(api.WithAuthentik(api.AuthentikConfig{ + TrustedProxy: true, + PublicPaths: []string{" / ", "/docs/", "/docs"}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.AuthentikConfig() + want := []string{"/", "/docs"} + if len(cfg.PublicPaths) != len(want) { + t.Fatalf("expected %d public paths, got %v", len(want), cfg.PublicPaths) + } + for i, path := range want { + if cfg.PublicPaths[i] != path { + t.Fatalf("expected public path %q at index %d, got %q", path, i, cfg.PublicPaths[i]) + } + } +} + // ── Forward auth middleware ──────────────────────────────────────────── func TestForwardAuthHeaders_Good(t *testing.T) { diff --git a/authz_test.go b/authz_test.go index 4fa0c89..699fa7c 100644 --- a/authz_test.go +++ b/authz_test.go @@ -11,7 +11,7 @@ import ( "github.com/casbin/casbin/v2/model" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // casbinModel is a minimal RESTful ACL model for testing authorisation. diff --git a/bridge.go b/bridge.go index 269e844..a8768e4 100644 --- a/bridge.go +++ b/bridge.go @@ -59,6 +59,7 @@ var _ RouteGroup = (*ToolBridge)(nil) var _ DescribableGroup = (*ToolBridge)(nil) var toolNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +var mcpServerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,63}$`) var regexPatternCache sync.Map // NewToolBridge creates a bridge that mounts tool endpoints at basePath. @@ -206,6 +207,12 @@ func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] { } } +// IsValidMCPServerID reports whether id is safe to use as an MCP HTTP bridge +// server_id path segment or filesystem-backed lookup key. +func IsValidMCPServerID(id string) bool { + return isValidMCPServerID(id) +} + func (b *ToolBridge) snapshotTools() []boundTool { if len(b.tools) == 0 { return nil @@ -278,6 +285,18 @@ func isValidToolName(name string) bool { return toolNamePattern.MatchString(name) } +func isValidMCPServerID(id string) bool { + if id == "" { + return false + } + + if strings.ContainsRune(id, '\x00') || strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") { + return false + } + + return mcpServerIDPattern.MatchString(id) +} + // normaliseToolBridgePath coerces the bridge base path into a stable form. // A blank value maps to "/" so the bridge still has a valid mount point. func normaliseToolBridgePath(path string) string { @@ -650,6 +669,9 @@ func schemaInt(value any) (int, bool) { case int64: return int(v), true case uint: + if v > math.MaxInt { + return 0, false + } return int(v), true case uint8: return int(v), true @@ -658,6 +680,9 @@ func schemaInt(value any) (int, bool) { case uint32: return int(v), true case uint64: + if v > math.MaxInt { + return 0, false + } return int(v), true case float64: if v == float64(int(v)) { @@ -1084,6 +1109,9 @@ func compareIntegralNumericToFloat64(value *big.Int, limit float64) (int, bool) return 0, false } + // #nosec G115 -- BitLen() returns int >= 0; +1 stays positive and within + // int range (BitLen on a *big.Int can't reach math.MaxInt minus one in + // practice). Cast to uint cannot overflow. precision := uint(value.BitLen() + 1) if precision < 64 { precision = 64 diff --git a/bridge_internal_test.go b/bridge_internal_test.go new file mode 100644 index 0000000..971ebe5 --- /dev/null +++ b/bridge_internal_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "math" + "testing" +) + +// TestBridge_schemaInt_overflow_Bad verifies that uint/uint64 values exceeding +// math.MaxInt return (0, false) instead of silently wrapping to a negative int. +// +// G115 (gosec): integer overflow on coercion would let attacker-controlled +// JSON numbers >= 2^63 wrap to negative values, which downstream feeds into +// range checks / slice indices / array sizes with wrong sign. +func TestBridge_schemaInt_overflow_Bad(t *testing.T) { + tests := []struct { + name string + value any + }{ + {name: "uint64 max", value: uint64(math.MaxUint64)}, + {name: "uint64 over MaxInt", value: uint64(math.MaxInt) + 1}, + {name: "uint over MaxInt", value: uint(math.MaxInt) + 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := schemaInt(tt.value) + if ok { + t.Errorf("schemaInt(%v) returned ok=true; expected false on overflow", tt.value) + } + if got != 0 { + t.Errorf("schemaInt(%v) returned %d; expected 0 on overflow", tt.value, got) + } + }) + } +} + +// TestBridge_schemaInt_inrange_Good verifies that valid values still convert. +func TestBridge_schemaInt_inrange_Good(t *testing.T) { + tests := []struct { + name string + value any + want int + }{ + {name: "uint zero", value: uint(0), want: 0}, + {name: "uint small", value: uint(42), want: 42}, + {name: "uint64 small", value: uint64(100), want: 100}, + {name: "uint64 maxint", value: uint64(math.MaxInt), want: math.MaxInt}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := schemaInt(tt.value) + if !ok { + t.Errorf("schemaInt(%v) returned ok=false; expected true", tt.value) + } + if got != tt.want { + t.Errorf("schemaInt(%v) = %d; want %d", tt.value, got, tt.want) + } + }) + } +} + +// TestBridge_schemaInt_boundary_Ugly tests the exact MaxInt boundary — +// MaxInt itself must succeed, MaxInt+1 must fail. +func TestBridge_schemaInt_boundary_Ugly(t *testing.T) { + // uint64(MaxInt) — boundary, must succeed + if got, ok := schemaInt(uint64(math.MaxInt)); !ok || got != math.MaxInt { + t.Errorf("schemaInt(uint64(MaxInt)) = (%d, %v); want (MaxInt, true)", got, ok) + } + // uint64(MaxInt)+1 — one over boundary, must fail + if _, ok := schemaInt(uint64(math.MaxInt) + 1); ok { + t.Error("schemaInt(uint64(MaxInt)+1) returned ok=true; expected false (boundary)") + } +} diff --git a/bridge_test.go b/bridge_test.go index ce15a8d..d9d3dc3 100644 --- a/bridge_test.go +++ b/bridge_test.go @@ -7,11 +7,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── ToolBridge ───────────────────────────────────────────────────────── @@ -199,6 +200,51 @@ func TestToolBridge_Ugly_RejectsUnsafeToolNameForms(t *testing.T) { } } +func TestMCPServerID_Good_AcceptsSafeIDs(t *testing.T) { + cases := []string{ + "core", + "core-mcp", + "A1", + "server-01", + "a" + strings.Repeat("b", 63), + } + + for _, id := range cases { + t.Run(id, func(t *testing.T) { + if !api.IsValidMCPServerID(id) { + t.Fatalf("expected server_id %q to be accepted", id) + } + }) + } +} + +func TestMCPServerID_Bad_RejectsMalformedIDs(t *testing.T) { + cases := []string{ + "", + " ", + "_core", + "-core", + "core_mcp", + "core.mcp", + "core/mcp", + "core\\mcp", + "../secrets", + "core/../secrets", + "/etc/passwd", + `C:\Windows`, + "core\x00mcp", + "a" + strings.Repeat("b", 64), + } + + for _, id := range cases { + t.Run(id, func(t *testing.T) { + if api.IsValidMCPServerID(id) { + t.Fatalf("expected server_id %q to be rejected", id) + } + }) + } +} + func TestToolBridge_Good_Describe(t *testing.T) { bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ diff --git a/brotli_test.go b/brotli_test.go index 309d4a1..c0fff55 100644 --- a/brotli_test.go +++ b/brotli_test.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── WithBrotli ──────────────────────────────────────────────────────── diff --git a/cache_config_test.go b/cache_config_test.go new file mode 100644 index 0000000..eb8d18c --- /dev/null +++ b/cache_config_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "testing" + "time" + + api "dappco.re/go/api" +) + +// TestCacheConfig_Good_SnapshotsConfiguredEngine verifies that CacheConfig +// reflects the cache limits supplied during engine construction. +func TestCacheConfig_Good_SnapshotsConfiguredEngine(t *testing.T) { + e, err := api.New(api.WithCacheLimits(5*time.Minute, 10, 1024)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.CacheConfig() + + if !cfg.Enabled { + t.Fatal("expected cache config to be enabled") + } + if cfg.TTL != 5*time.Minute { + t.Fatalf("expected TTL %v, got %v", 5*time.Minute, cfg.TTL) + } + if cfg.MaxEntries != 10 { + t.Fatalf("expected MaxEntries 10, got %d", cfg.MaxEntries) + } + if cfg.MaxBytes != 1024 { + t.Fatalf("expected MaxBytes 1024, got %d", cfg.MaxBytes) + } +} + +// TestCacheConfig_Bad_NilEngineReturnsZeroValue verifies the nil-receiver +// guard returns an empty snapshot instead of panicking. +func TestCacheConfig_Bad_NilEngineReturnsZeroValue(t *testing.T) { + var e *api.Engine + + cfg := e.CacheConfig() + if cfg != (api.CacheConfig{}) { + t.Fatalf("expected zero-value cache config, got %+v", cfg) + } +} + +// TestCacheConfig_Ugly_UnconfiguredEngineStaysDisabled verifies that an +// engine without cache middleware reports a disabled snapshot. +func TestCacheConfig_Ugly_UnconfiguredEngineStaysDisabled(t *testing.T) { + e, err := api.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.CacheConfig() + if cfg.Enabled { + t.Fatal("expected cache config to remain disabled") + } + if cfg.TTL != 0 || cfg.MaxEntries != 0 || cfg.MaxBytes != 0 { + t.Fatalf("expected zero cache settings, got %+v", cfg) + } +} diff --git a/cache_test.go b/cache_test.go index 4971240..54e5c80 100644 --- a/cache_test.go +++ b/cache_test.go @@ -14,7 +14,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // cacheCounterGroup registers routes that increment a counter on each call, @@ -444,6 +444,34 @@ func TestWithCache_Ugly_NonPositiveTTLDisablesMiddleware(t *testing.T) { } } +// TestWithCache_Ugly_ExplicitZeroLimitsDisableMiddleware verifies that +// explicitly passing zero entry and byte limits disables caching even when +// the TTL is positive. +func TestWithCache_Ugly_ExplicitZeroLimitsDisableMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + grp := &cacheCounterGroup{} + e, _ := api.New(api.WithCache(5*time.Second, 0, 0)) + e.Register(grp) + + h := e.Handler() + + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected request %d to succeed with disabled cache, got %d", i+1, w.Code) + } + if got := w.Header().Get("X-Cache"); got != "" { + t.Fatalf("expected no X-Cache header with disabled cache, got %q", got) + } + } + + if grp.counter.Load() != 2 { + t.Fatalf("expected counter=2 with disabled cache, got %d", grp.counter.Load()) + } +} + func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) { gin.SetMode(gin.TestMode) grp := &cacheCounterGroup{} diff --git a/chat_completions.go b/chat_completions.go index 155160e..f825274 100644 --- a/chat_completions.go +++ b/chat_completions.go @@ -17,7 +17,7 @@ import ( "unicode" "dappco.re/go/core" - inference "dappco.re/go/core/inference" + inference "dappco.re/go/inference" "github.com/gin-gonic/gin" "gopkg.in/yaml.v3" @@ -718,38 +718,46 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference. created := time.Now().Unix() completionID := newChatCompletionID() - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Status(200) - c.Writer.Flush() + extractor := NewThinkingExtractor() + streamStarted := false + emittedContent := "" + writePrimingChunk := func() { + if streamStarted { + return + } - // Emit the OpenAI-style role priming chunk before any generated content. - primingChunk := ChatCompletionChunk{ - ID: completionID, - Object: "chat.completion.chunk", - Created: created, - Model: req.Model, - Choices: []ChatChunkChoice{ - { - Index: 0, - Delta: ChatMessageDelta{ - Role: "assistant", + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Status(200) + c.Writer.Flush() + + primingChunk := ChatCompletionChunk{ + ID: completionID, + Object: "chat.completion.chunk", + Created: created, + Model: req.Model, + Choices: []ChatChunkChoice{ + { + Index: 0, + Delta: ChatMessageDelta{ + Role: "assistant", + }, + FinishReason: nil, }, - FinishReason: nil, }, - }, - } - if encoded, encodeErr := json.Marshal(primingChunk); encodeErr == nil { - c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", encoded)) - c.Writer.Flush() - } + } + if encoded, encodeErr := json.Marshal(primingChunk); encodeErr == nil { + c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", encoded)) + c.Writer.Flush() + } - extractor := NewThinkingExtractor() - sentAny := true - emittedContent := "" + streamStarted = true + } for tok := range model.Chat(ctx, messages, opts...) { + writePrimingChunk() + contentDelta, thoughtDelta := extractor.writeDeltas(tok.Text) candidateContent := emittedContent + contentDelta stopCut, stopHit := firstStopSequenceCut(candidateContent, stopSequences) @@ -790,7 +798,6 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference. if encoded, encodeErr := json.Marshal(chunk); encodeErr == nil { c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", encoded)) c.Writer.Flush() - sentAny = true } if stopHit { emittedContent = candidateContent[:stopCut] @@ -802,13 +809,15 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference. } } - if err := model.Err(); err != nil && !sentAny { - if strings.Contains(strings.ToLower(err.Error()), "loading") { - writeChatCompletionError(c, http.StatusServiceUnavailable, "model_loading", "model", err.Error(), "") + if err := model.Err(); err != nil { + if !streamStarted { + if strings.Contains(strings.ToLower(err.Error()), "loading") { + writeChatCompletionError(c, http.StatusServiceUnavailable, "model_loading", "model", err.Error(), "") + return + } + writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "") return } - writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "") - return } finishReason := "stop" @@ -820,6 +829,8 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference. finishReason = "length" } + writePrimingChunk() + finished := finishReason finalChunk := ChatCompletionChunk{ ID: completionID, @@ -1101,6 +1112,11 @@ func codeOrDefault(code, fallback string) string { } func newChatCompletionID() string { + // #nosec G404 -- chat completion IDs are correlation tokens (format + // "chatcmpl--<6digit>"), not security-sensitive secrets. The Unix + // timestamp already orders them; rand.Intn only disambiguates same-second + // collisions. crypto/rand would add entropy-budget pressure for zero + // security gain. Argus Mantis #320. return fmt.Sprintf("chatcmpl-%d-%06d", time.Now().Unix(), rand.Intn(1_000_000)) } diff --git a/chat_completions_internal_test.go b/chat_completions_internal_test.go index fa29951..7d0c9e7 100644 --- a/chat_completions_internal_test.go +++ b/chat_completions_internal_test.go @@ -14,7 +14,7 @@ import ( "strings" "testing" - inference "dappco.re/go/core/inference" + inference "dappco.re/go/inference" "github.com/gin-gonic/gin" ) @@ -679,3 +679,43 @@ func TestChatCompletions_ServeHTTP_Good_StreamingResponseEmitsSSEChunks(t *testi t.Fatalf("expected stream terminator, got %s", body) } } + +func TestChatCompletions_ServeHTTP_Bad_StreamingModelLoadingReturnsErrorBeforeBytes(t *testing.T) { + gin.SetMode(gin.TestMode) + + model := &chatModelStub{ + err: fmt.Errorf("model is loading"), + } + handler := newChatHandlerWithModel(model) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = newChatLoopbackRequest(t, `{ + "model": "lemer", + "messages": [{"role":"user","content":"hi"}], + "stream": true + }`) + + handler.ServeHTTP(ctx) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d (%s)", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Retry-After"); got != "10" { + t.Fatalf("expected Retry-After=10, got %q", got) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("expected JSON error content type, got %q", got) + } + + var payload chatCompletionErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("invalid JSON error response: %v", err) + } + if payload.Error.Code != "model_loading" { + t.Fatalf("expected model_loading code, got %q", payload.Error.Code) + } + if payload.Error.Param != "model" { + t.Fatalf("expected param=model, got %q", payload.Error.Param) + } +} diff --git a/chat_completions_test.go b/chat_completions_test.go index 62da49d..ed481b6 100644 --- a/chat_completions_test.go +++ b/chat_completions_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) func newLoopbackRequest(method, target, body string) *http.Request { diff --git a/client.go b/client.go index 6202a02..66a4519 100644 --- a/client.go +++ b/client.go @@ -700,17 +700,26 @@ func applyCookieValues(req *http.Request, values map[string]any) { } } +// applyCookieValue attaches an outbound request cookie for the given key. +// All four AddCookie sites construct cookies for an OUTBOUND http.Request — +// they are written to the Cookie request header (RFC 6265 §5.4). Secure / +// HttpOnly / SameSite are response-only Set-Cookie attributes (§5.2) and +// have no effect on outbound request cookies. G124 false-positive — verified +// no path echoes these into a server-side http.SetCookie. +// Cerberus mechanism review attached to Mantis #321. func applyCookieValue(req *http.Request, key string, value any) { switch v := value.(type) { case nil: return case []string: for _, item := range v { + //#nosec G124 -- outbound request cookie, not Set-Cookie response. req.AddCookie(&http.Cookie{Name: key, Value: item}) } return case []any: for _, item := range v { + //#nosec G124 -- outbound request cookie, not Set-Cookie response. req.AddCookie(&http.Cookie{Name: key, Value: core.Sprint(item)}) } return @@ -719,11 +728,13 @@ func applyCookieValue(req *http.Request, key string, value any) { rv := reflect.ValueOf(value) if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) && !(rv.Type().Elem().Kind() == reflect.Uint8) { for i := 0; i < rv.Len(); i++ { + //#nosec G124 -- outbound request cookie, not Set-Cookie response. req.AddCookie(&http.Cookie{Name: key, Value: core.Sprint(rv.Index(i).Interface())}) } return } + //#nosec G124 -- outbound request cookie, not Set-Cookie response. req.AddCookie(&http.Cookie{Name: key, Value: core.Sprint(value)}) } diff --git a/client_test.go b/client_test.go index 06bb923..1914576 100644 --- a/client_test.go +++ b/client_test.go @@ -15,7 +15,7 @@ import ( "slices" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) type trackingReadCloser struct { diff --git a/cmd/api/cmd.go b/cmd/api/cmd.go index 9ff267d..9845fb5 100644 --- a/cmd/api/cmd.go +++ b/cmd/api/cmd.go @@ -15,7 +15,7 @@ package api import ( "dappco.re/go/core" - "dappco.re/go/core/cli/pkg/cli" + "dappco.re/go/cli/pkg/cli" ) func init() { diff --git a/cmd/api/cmd_args_test.go b/cmd/api/cmd_args_test.go new file mode 100644 index 0000000..52f5b95 --- /dev/null +++ b/cmd/api/cmd_args_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "reflect" + "testing" +) + +// TestCmdArgs_SplitUniqueCSV_Good_TrimsAndDeduplicates verifies comma- +// separated values are trimmed, deduplicated, and returned in first-seen +// order. +func TestCmdArgs_SplitUniqueCSV_Good_TrimsAndDeduplicates(t *testing.T) { + got := splitUniqueCSV(" go, python ,go, ruby ") + want := []string{"go", "python", "ruby"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} + +// TestCmdArgs_SplitUniqueCSV_Bad_EmptyInputReturnsNil verifies empty input +// produces no values. +func TestCmdArgs_SplitUniqueCSV_Bad_EmptyInputReturnsNil(t *testing.T) { + if got := splitUniqueCSV(""); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +// TestCmdArgs_SplitUniqueCSV_Ugly_IgnoresBlankSegments verifies separator +// noise and repeated whitespace do not leak empty entries into the result. +func TestCmdArgs_SplitUniqueCSV_Ugly_IgnoresBlankSegments(t *testing.T) { + got := splitUniqueCSV(" , , go, , python,go , ") + want := []string{"go", "python"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} + +// TestCmdArgs_NormalisePublicPaths_Good_AddsLeadingSlashAndDeduplicates +// verifies relative paths are promoted to absolute route paths and repeated +// entries are skipped. +func TestCmdArgs_NormalisePublicPaths_Good_AddsLeadingSlashAndDeduplicates(t *testing.T) { + got := normalisePublicPaths([]string{"docs", "/docs", " api "}) + want := []string{"/docs", "/api"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} + +// TestCmdArgs_NormalisePublicPaths_Bad_EmptyInputReturnsNil verifies that +// empty path lists stay empty. +func TestCmdArgs_NormalisePublicPaths_Bad_EmptyInputReturnsNil(t *testing.T) { + if got := normalisePublicPaths(nil); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +// TestCmdArgs_NormalisePublicPaths_Ugly_BlankEntriesReturnNil verifies that +// a slice containing only whitespace collapses to no public paths. +func TestCmdArgs_NormalisePublicPaths_Ugly_BlankEntriesReturnNil(t *testing.T) { + if got := normalisePublicPaths([]string{" ", "\t", ""}); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +// TestCmdArgs_NormalisePublicPaths_Ugly_NormalisesRootAndTrailingSlashes +// verifies awkward path forms still collapse to stable public path values. +func TestCmdArgs_NormalisePublicPaths_Ugly_NormalisesRootAndTrailingSlashes(t *testing.T) { + got := normalisePublicPaths([]string{" / ", "/status/", " status// ", "/status"}) + want := []string{"/", "/status"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index 226ab28..ee46350 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -7,10 +7,10 @@ import ( "os" core "dappco.re/go/core" - "dappco.re/go/core/cli/pkg/cli" + "dappco.re/go/cli/pkg/cli" - goapi "dappco.re/go/core/api" - coreio "dappco.re/go/core/io" + goapi "dappco.re/go/api" + coreio "dappco.re/go/io" ) const ( diff --git a/cmd/api/cmd_sdk_test.go b/cmd/api/cmd_sdk_test.go index da27b41..402417b 100644 --- a/cmd/api/cmd_sdk_test.go +++ b/cmd/api/cmd_sdk_test.go @@ -3,13 +3,17 @@ package api import ( + "os" + "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" core "dappco.re/go/core" + "dappco.re/go/cli/pkg/cli" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // TestCmdSdk_AddSDKCommand_Good verifies the sdk command registers under @@ -58,6 +62,65 @@ func TestCmdSdk_SdkAction_Bad_EmptyLanguageList(t *testing.T) { } } +// TestCmdSdk_SdkAction_Good_InvokesGeneratorForUniqueLanguages verifies the +// happy path using a fake openapi-generator-cli binary on PATH so the action +// can be exercised deterministically without external dependencies. +func TestCmdSdk_SdkAction_Good_InvokesGeneratorForUniqueLanguages(t *testing.T) { + workDir := t.TempDir() + + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("failed to create fake bin dir: %v", err) + } + + logFile := filepath.Join(workDir, "generator-args.log") + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$SDK_ACTION_LOG\"\nexit 0\n" + if err := os.WriteFile(filepath.Join(binDir, "openapi-generator-cli"), []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake generator: %v", err) + } + + path := os.Getenv("PATH") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+path) + t.Setenv("SDK_ACTION_LOG", logFile) + + if err := cli.Init(cli.Options{AppName: "core-api-test"}); err != nil { + t.Fatalf("failed to initialise CLI runtime: %v", err) + } + t.Cleanup(cli.Shutdown) + + opts := core.NewOptions( + core.Option{Key: "lang", Value: " go , python , go "}, + core.Option{Key: "output", Value: filepath.Join(workDir, "sdk")}, + ) + + r := sdkAction(opts) + if !r.OK { + t.Fatalf("expected sdk action to succeed, got %v", r.Value) + } + + for _, lang := range []string{"go", "python"} { + if _, err := os.Stat(filepath.Join(workDir, "sdk", lang)); err != nil { + t.Fatalf("expected output directory for %s: %v", lang, err) + } + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("expected generator log to exist: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 generator invocations, got %d: %q", len(lines), string(data)) + } + if !strings.Contains(lines[0], "-g go") || !strings.Contains(lines[0], "packageName=lethean") { + t.Fatalf("expected default package name and go generator in first invocation, got %q", lines[0]) + } + if !strings.Contains(lines[1], "-g python") || !strings.Contains(lines[1], "packageName=lethean") { + t.Fatalf("expected default package name and python generator in second invocation, got %q", lines[1]) + } +} + // TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge verifies the SDK // builder always exposes the bundled tools route group. func TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge(t *testing.T) { diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index 7180866..e27d459 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -7,9 +7,9 @@ import ( "os" core "dappco.re/go/core" - "dappco.re/go/core/cli/pkg/cli" + "dappco.re/go/cli/pkg/cli" - goapi "dappco.re/go/core/api" + goapi "dappco.re/go/api" ) const defaultSpecToolBridgePath = "/v1/tools" diff --git a/cmd/api/cmd_spec_test.go b/cmd/api/cmd_spec_test.go index fbd2c0f..d451509 100644 --- a/cmd/api/cmd_spec_test.go +++ b/cmd/api/cmd_spec_test.go @@ -11,7 +11,7 @@ import ( core "dappco.re/go/core" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) type specCmdStubGroup struct{} @@ -250,6 +250,43 @@ func TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback(t *testing.T) { } } +// TestCmdSpec_ParseSecuritySchemes_Good_ParsesJSON verifies the helper +// accepts valid JSON and preserves nested values. +func TestCmdSpec_ParseSecuritySchemes_Good_ParsesJSON(t *testing.T) { + schemes, err := parseSecuritySchemes(`{"apiKeyAuth":{"type":"apiKey","in":"header"}}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + scheme, ok := schemes["apiKeyAuth"].(map[string]any) + if !ok { + t.Fatalf("expected apiKeyAuth security scheme, got %v", schemes) + } + if scheme["type"] != "apiKey" || scheme["in"] != "header" { + t.Fatalf("expected preserved security scheme, got %v", scheme) + } +} + +// TestCmdSpec_ParseSecuritySchemes_Bad_RejectsMalformedJSON verifies invalid +// input is wrapped as a parse error. +func TestCmdSpec_ParseSecuritySchemes_Bad_RejectsMalformedJSON(t *testing.T) { + if _, err := parseSecuritySchemes(`{"apiKeyAuth":`); err == nil { + t.Fatal("expected malformed JSON to fail") + } +} + +// TestCmdSpec_ParseSecuritySchemes_Ugly_EmptyInputReturnsNil verifies that +// blank input behaves like an omitted flag. +func TestCmdSpec_ParseSecuritySchemes_Ugly_EmptyInputReturnsNil(t *testing.T) { + schemes, err := parseSecuritySchemes(" ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if schemes != nil { + t.Fatalf("expected nil schemes for blank input, got %v", schemes) + } +} + // TestSpecGroupsIter_Good_DeduplicatesExtraBridge verifies the iterator does // not emit a duplicate when the registered groups already contain a tool // bridge with the same base path. diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go new file mode 100644 index 0000000..ff68217 --- /dev/null +++ b/cmd/api/cmd_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "testing" + + core "dappco.re/go/core" +) + +// TestCmd_AddAPICommands_Good_RegistersBothCommandGroups verifies the root +// command registrar wires the spec and SDK command groups onto the Core +// command tree. +func TestCmd_AddAPICommands_Good_RegistersBothCommandGroups(t *testing.T) { + c := core.New() + + AddAPICommands(c) + + for _, path := range []string{"api/spec", "build/spec", "api/sdk", "build/sdk"} { + r := c.Command(path) + if !r.OK { + t.Fatalf("expected %s command to be registered", path) + } + cmd, ok := r.Value.(*core.Command) + if !ok { + t.Fatalf("expected *core.Command for %s, got %T", path, r.Value) + } + if cmd.Action == nil { + t.Fatalf("expected non-nil Action on %s", path) + } + if cmd.Description == "" { + t.Fatalf("expected Description on %s", path) + } + } +} diff --git a/cmd/api/spec_builder.go b/cmd/api/spec_builder.go index ab3c2d8..d97e8ac 100644 --- a/cmd/api/spec_builder.go +++ b/cmd/api/spec_builder.go @@ -7,7 +7,7 @@ import ( core "dappco.re/go/core" - goapi "dappco.re/go/core/api" + goapi "dappco.re/go/api" ) type specBuilderConfig struct { diff --git a/cmd/api/spec_groups_iter.go b/cmd/api/spec_groups_iter.go index e1a380e..417b918 100644 --- a/cmd/api/spec_groups_iter.go +++ b/cmd/api/spec_groups_iter.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" - goapi "dappco.re/go/core/api" + goapi "dappco.re/go/api" ) // specGroupsIter snapshots the registered spec groups and appends one optional diff --git a/codegen.go b/codegen.go index e031dea..005cbe2 100644 --- a/codegen.go +++ b/codegen.go @@ -10,13 +10,22 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "slices" "strings" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" ) +// packageNameRe constrains SDKGenerator.PackageName to identifier-shaped +// values so it cannot smuggle additional CLI flags through +// --additional-properties packageName=. Defence-in-depth per +// Cerberus mechanism review on Mantis #322 — current callsite is operator- +// only via cmd/api/cmd_sdk.go, but future consumers binding request input +// to this field would re-open the flag-injection surface without it. +var packageNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + // Supported SDK target languages. var supportedLanguages = map[string]string{ "go": "go", @@ -84,6 +93,11 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) error { return coreerr.E("SDKGenerator.Generate", "output directory is required", nil) } + if g.PackageName != "" && !packageNameRe.MatchString(g.PackageName) { + return coreerr.E("SDKGenerator.Generate", + fmt.Sprintf("package name %q rejected: must match %s", g.PackageName, packageNameRe.String()), nil) + } + if !g.Available() { return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli not installed", nil) } @@ -94,6 +108,13 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) error { } args := g.buildArgs(specPath, generator, outputDir) + // Command name is a string literal (zero attacker-influence). Args are + // constructed from a closed allowlist of generator names (supportedLanguages) + // and operator-supplied spec/output paths. Current callsite is operator-only + // via cmd/api/cmd_sdk.go. PackageName is regex-validated above to prevent + // flag-injection through --additional-properties. Cerberus mechanism review + // attached to Mantis #322. + //#nosec G204 -- command literal; args from closed allowlist + operator config + validated PackageName. cmd := exec.CommandContext(ctx, "openapi-generator-cli", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/codegen_test.go b/codegen_test.go index 5d3b580..44d4b5b 100644 --- a/codegen_test.go +++ b/codegen_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── SDKGenerator tests ───────────────────────────────────────────────────── @@ -193,3 +193,74 @@ func TestSDKGenerator_Good_Available(t *testing.T) { // Just verify it returns a bool and does not panic. _ = gen.Available() } + +// TestSDKGenerator_Generate_PackageNameRejected_Bad verifies the regex-validation +// hardening from Mantis #322 — PackageName containing flag-injection characters +// is rejected before exec.CommandContext is reached. +func TestSDKGenerator_Generate_PackageNameRejected_Bad(t *testing.T) { + tmp := t.TempDir() + specPath := filepath.Join(tmp, "spec.yaml") + if err := os.WriteFile(specPath, []byte("openapi: 3.0.0\n"), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + + rejects := []string{ + "foo --extra=evil", // space + flag injection + "foo;rm -rf /", // command separator + "foo bar", // bare space + "--shell-injection", // leading dash + "foo$(whoami)", // command substitution + } + for _, name := range rejects { + t.Run(name, func(t *testing.T) { + gen := &api.SDKGenerator{ + SpecPath: specPath, + OutputDir: tmp, + PackageName: name, + } + err := gen.Generate(context.Background(), "go") + if err == nil { + t.Errorf("expected rejection for PackageName=%q, got nil error", name) + return + } + if !strings.Contains(err.Error(), "package name") { + t.Errorf("expected rejection error containing 'package name', got %q", err.Error()) + } + }) + } +} + +// TestSDKGenerator_Generate_PackageNameAccepted_Good verifies legitimate names +// pass the regex; any subsequent error must NOT be the regex-rejection. +func TestSDKGenerator_Generate_PackageNameAccepted_Good(t *testing.T) { + accepts := []string{ + "foo", + "FooBar", + "foo_bar", + "foo-bar", + "Foo123", + "a", + } + tmp := t.TempDir() + specPath := filepath.Join(tmp, "spec.yaml") + if err := os.WriteFile(specPath, []byte("openapi: 3.0.0\n"), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + for _, name := range accepts { + t.Run(name, func(t *testing.T) { + gen := &api.SDKGenerator{ + SpecPath: specPath, + OutputDir: tmp, + PackageName: name, + } + err := gen.Generate(context.Background(), "go") + // Likely fails because openapi-generator-cli isn't installed in + // CI; the error MUST NOT be the regex-rejection ("package name + // X rejected"). + if err != nil && strings.Contains(err.Error(), "package name") && + strings.Contains(err.Error(), "rejected") { + t.Errorf("name %q was unexpectedly rejected by regex: %v", name, err) + } + }) + } +} diff --git a/composer.json b/composer.json index f449c57..41f14c0 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "keywords": ["api", "rest", "laravel", "openapi"], "license": "EUPL-1.2", "require": { - "dedoc/scramble": "^0.12", + "dedoc/scramble": "^0.13", "php": "^8.2", "laravel/passport": "^12.0", "lthn/php": "*", @@ -23,6 +23,7 @@ "autoload": { "psr-4": { "Core\\Api\\": "src/php/src/Api/", + "Core\\Tenant\\": "../php-tenant/", "Core\\Front\\Api\\": "src/php/src/Front/Api/", "Core\\Website\\Api\\": "src/php/src/Website/Api/" } diff --git a/export.go b/export.go index fadff80..e24c536 100644 --- a/export.go +++ b/export.go @@ -13,8 +13,8 @@ import ( "gopkg.in/yaml.v3" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" ) // ExportSpec generates the OpenAPI spec and writes it to w. diff --git a/export_test.go b/export_test.go index e9d94a4..639d5e6 100644 --- a/export_test.go +++ b/export_test.go @@ -15,7 +15,7 @@ import ( "github.com/gin-gonic/gin" "gopkg.in/yaml.v3" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── ExportSpec tests ───────────────────────────────────────────────────── diff --git a/expvar_test.go b/expvar_test.go index 89c8793..5ccd402 100644 --- a/expvar_test.go +++ b/expvar_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Expvar runtime metrics endpoint ───────────────────────────────── diff --git a/go.mod b/go.mod index 3295338..912369d 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ -module dappco.re/go/core/api +module dappco.re/go/api go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/core/cli v0.5.2 - dappco.re/go/core/inference v0.3.0 - dappco.re/go/core/io v0.1.7 - dappco.re/go/core/log v0.1.2 + dappco.re/go/cli v0.8.0-alpha.1 + dappco.re/go/inference v0.8.0-alpha.1 + dappco.re/go/io v0.8.0-alpha.1 + dappco.re/go/log v0.8.0-alpha.1 github.com/99designs/gqlgen v0.17.88 github.com/andybalholm/brotli v1.2.0 github.com/casbin/casbin/v2 v2.135.0 @@ -40,7 +40,7 @@ require ( ) require ( - dappco.re/go/core/i18n v0.2.3 // indirect + dappco.re/go/i18n v0.8.0-alpha.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/graphql_config_test.go b/graphql_config_test.go index 83bbc8d..e39be60 100644 --- a/graphql_config_test.go +++ b/graphql_config_test.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) func TestEngine_GraphQLConfig_Good_SnapshotsCurrentSettings(t *testing.T) { diff --git a/graphql_test.go b/graphql_test.go index 48b5ace..5d8ec79 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -15,7 +15,7 @@ import ( "github.com/vektah/gqlparser/v2" "github.com/vektah/gqlparser/v2/ast" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // newTestSchema creates a minimal ExecutableSchema that responds to { name } diff --git a/group.go b/group.go index f45fe17..216b6be 100644 --- a/group.go +++ b/group.go @@ -25,6 +25,60 @@ type RouteGroup interface { RegisterRoutes(rg *gin.RouterGroup) } +// Describable allows a route handler or controller to expose OpenAPI metadata +// without coupling callers to RouteDescription construction details. +// +// Example: +// +// var d api.Describable = &myHandler{} +type Describable interface { + // Describe returns the handler's request/response description. + Describe() RouteDescription + // OperationID returns the OpenAPI operation identifier. + OperationID() string + // Tags returns the OpenAPI tags associated with the operation. + Tags() []string + // Summary returns a short operation summary. + Summary() string + // Description returns a longer operation description. + Description() string +} + +// Renderable allows a route handler or controller to expose UI rendering +// hints that spec consumers can surface via vendor extensions. +// +// Example: +// +// var r api.Renderable = &myHandler{} +type Renderable interface { + // Render returns UI hints for the operation. + Render() RenderHints +} + +// RenderHints describes how a UI may present an operation. +type RenderHints struct { + Kind string `json:"kind,omitempty"` // "form" | "table" | "modal" | "grid" + Fields []FieldHint `json:"fields,omitempty"` // Form fields with validation hints. + Actions []ActionHint `json:"actions,omitempty"` // Inline action buttons. +} + +// FieldHint describes an input field for UI rendering. +type FieldHint struct { + Name string `json:"name,omitempty"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + Required bool `json:"required,omitempty"` + Validation map[string]any `json:"validation,omitempty"` +} + +// ActionHint describes an inline action a UI can render for an operation. +type ActionHint struct { + Name string `json:"name,omitempty"` + Label string `json:"label,omitempty"` + Method string `json:"method,omitempty"` + Variant string `json:"variant,omitempty"` +} + // StreamGroup optionally declares WebSocket channels a subsystem publishes to. // // Example: @@ -80,6 +134,10 @@ type RouteDescription struct { Summary string // Short summary Description string // Long description Tags []string // OpenAPI tags for grouping + // Handler optionally points at the route handler/controller that implements + // Describable and/or Renderable. RegisterRoutes still owns actual Gin + // wiring; this field is metadata-only for spec generation. + Handler any // CacheControl hints the framework that successful responses for this // operation should advertise the given Cache-Control policy in docs. CacheControl string diff --git a/group_test.go b/group_test.go index 1034d47..de7179e 100644 --- a/group_test.go +++ b/group_test.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Stub implementations ──────────────────────────────────────────────── diff --git a/gzip_test.go b/gzip_test.go index 386617f..01178c6 100644 --- a/gzip_test.go +++ b/gzip_test.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── WithGzip ────────────────────────────────────────────────────────── diff --git a/httpsign_test.go b/httpsign_test.go index 2606dda..46a07b8 100644 --- a/httpsign_test.go +++ b/httpsign_test.go @@ -17,7 +17,7 @@ import ( "github.com/gin-contrib/httpsign/crypto" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) const testSecretKey = "test-secret-key-for-hmac-sha256" diff --git a/i18n_test.go b/i18n_test.go index b56e5cf..2512e4d 100644 --- a/i18n_test.go +++ b/i18n_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/location_test.go b/location_test.go index db292d7..5b6c5a5 100644 --- a/location_test.go +++ b/location_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-contrib/location/v2" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/middleware_test.go b/middleware_test.go index aa3c057..34ae931 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/modernization_test.go b/modernization_test.go index 73a7224..3e8798b 100644 --- a/modernization_test.go +++ b/modernization_test.go @@ -5,9 +5,8 @@ package api_test import ( "slices" "testing" - "time" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) func TestEngine_GroupsIter(t *testing.T) { @@ -95,108 +94,6 @@ func TestEngine_ChannelsIter_Good_SnapshotsCurrentChannels(t *testing.T) { } } -func TestEngine_CacheConfig_Good_SnapshotsCurrentSettings(t *testing.T) { - e, _ := api.New(api.WithCacheLimits(5*time.Minute, 10, 1024)) - - cfg := e.CacheConfig() - - if !cfg.Enabled { - t.Fatal("expected cache config to be enabled") - } - if cfg.TTL != 5*time.Minute { - t.Fatalf("expected TTL %v, got %v", 5*time.Minute, cfg.TTL) - } - if cfg.MaxEntries != 10 { - t.Fatalf("expected MaxEntries 10, got %d", cfg.MaxEntries) - } - if cfg.MaxBytes != 1024 { - t.Fatalf("expected MaxBytes 1024, got %d", cfg.MaxBytes) - } -} - -func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) { - broker := api.NewSSEBroker() - e, err := api.New( - api.WithSwagger("Runtime API", "Runtime snapshot", "1.2.3"), - api.WithSwaggerPath("/docs"), - api.WithCacheLimits(5*time.Minute, 10, 1024), - api.WithGraphQL(newTestSchema(), api.WithPlayground()), - api.WithI18n(api.I18nConfig{ - DefaultLocale: "en-GB", - Supported: []string{"en-GB", "fr"}, - }), - api.WithWSPath("/socket"), - api.WithSSE(broker), - api.WithSSEPath("/events"), - api.WithAuthentik(api.AuthentikConfig{ - Issuer: "https://auth.example.com", - ClientID: "runtime-client", - TrustedProxy: true, - PublicPaths: []string{"/public", "/docs"}, - }), - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cfg := e.RuntimeConfig() - - if !cfg.Swagger.Enabled { - t.Fatal("expected swagger snapshot to be enabled") - } - if cfg.Swagger.Path != "/docs" { - t.Fatalf("expected swagger path /docs, got %q", cfg.Swagger.Path) - } - if cfg.Transport.SwaggerPath != "/docs" { - t.Fatalf("expected transport swagger path /docs, got %q", cfg.Transport.SwaggerPath) - } - if cfg.Transport.GraphQLPlaygroundPath != "/graphql/playground" { - t.Fatalf("expected transport graphql playground path /graphql/playground, got %q", cfg.Transport.GraphQLPlaygroundPath) - } - if !cfg.Cache.Enabled || cfg.Cache.TTL != 5*time.Minute { - t.Fatalf("expected cache snapshot to be populated, got %+v", cfg.Cache) - } - if !cfg.GraphQL.Enabled { - t.Fatal("expected GraphQL snapshot to be enabled") - } - if cfg.GraphQL.Path != "/graphql" { - t.Fatalf("expected GraphQL path /graphql, got %q", cfg.GraphQL.Path) - } - if !cfg.GraphQL.Playground { - t.Fatal("expected GraphQL playground snapshot to be enabled") - } - if cfg.GraphQL.PlaygroundPath != "/graphql/playground" { - t.Fatalf("expected GraphQL playground path /graphql/playground, got %q", cfg.GraphQL.PlaygroundPath) - } - if cfg.I18n.DefaultLocale != "en-GB" { - t.Fatalf("expected default locale en-GB, got %q", cfg.I18n.DefaultLocale) - } - if !slices.Equal(cfg.I18n.Supported, []string{"en-GB", "fr"}) { - t.Fatalf("expected supported locales [en-GB fr], got %v", cfg.I18n.Supported) - } - if cfg.Authentik.Issuer != "https://auth.example.com" { - t.Fatalf("expected Authentik issuer https://auth.example.com, got %q", cfg.Authentik.Issuer) - } - if cfg.Authentik.ClientID != "runtime-client" { - t.Fatalf("expected Authentik client ID runtime-client, got %q", cfg.Authentik.ClientID) - } - if !cfg.Authentik.TrustedProxy { - t.Fatal("expected Authentik trusted proxy to be enabled") - } - if !slices.Equal(cfg.Authentik.PublicPaths, []string{"/public", "/docs"}) { - t.Fatalf("expected Authentik public paths [/public /docs], got %v", cfg.Authentik.PublicPaths) - } -} - -func TestEngine_RuntimeConfig_Good_EmptyOnNilEngine(t *testing.T) { - var e *api.Engine - - cfg := e.RuntimeConfig() - if cfg.Swagger.Enabled || cfg.Transport.SwaggerEnabled || cfg.GraphQL.Enabled || cfg.Cache.Enabled || cfg.I18n.DefaultLocale != "" || cfg.Authentik.Issuer != "" { - t.Fatalf("expected zero-value runtime config, got %+v", cfg) - } -} - func TestEngine_AuthentikConfig_Good_SnapshotsCurrentSettings(t *testing.T) { e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{ Issuer: "https://auth.example.com", diff --git a/openapi.go b/openapi.go index a03830e..999f46b 100644 --- a/openapi.go +++ b/openapi.go @@ -416,7 +416,7 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { operation := map[string]any{ "summary": rd.Summary, "description": rd.Description, - "operationId": operationID(method, fullPath, operationIDs), + "operationId": resolvedOperationID(rd, method, fullPath, operationIDs), "responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, security, deprecated, rd.SunsetDate, replacement, deprecationHeaders, sb.CacheEnabled, rd.CacheControl), } if deprecated { @@ -467,6 +467,9 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { }, } } + if renderHints := resolvedRenderHints(rd); len(renderHints) > 0 { + operation["x-render-hints"] = renderHints + } // Create or extend path item. if existing, exists := paths[fullPath]; exists { @@ -1936,10 +1939,11 @@ func collectRouteDescriptions(g RouteGroup) []RouteDescription { descs := make([]RouteDescription, 0) for rd := range descIter { - if rd.Hidden { + resolved := resolveRouteDescription(rd) + if resolved.Hidden { continue } - descs = append(descs, cloneRouteDescription(rd)) + descs = append(descs, resolved) } return descs @@ -1976,6 +1980,197 @@ func routeDescriptions(g RouteGroup) iter.Seq[RouteDescription] { return nil } +func resolveRouteDescription(rd RouteDescription) RouteDescription { + resolved := cloneRouteDescription(rd) + handler := routeDescribable(resolved.Handler) + if handler == nil { + return resolved + } + + handlerDesc := cloneRouteDescription(handler.Describe()) + + if core.Trim(resolved.Method) == "" { + resolved.Method = handlerDesc.Method + } + if core.Trim(resolved.Path) == "" { + resolved.Path = handlerDesc.Path + } + if core.Trim(resolved.Summary) == "" { + resolved.Summary = firstNonEmpty(handler.Summary(), handlerDesc.Summary) + } + if core.Trim(resolved.Description) == "" { + resolved.Description = firstNonEmpty(handler.Description(), handlerDesc.Description) + } + if tags := cleanTags(resolved.Tags); len(tags) > 0 { + resolved.Tags = tags + } else if tags := cleanTags(handler.Tags()); len(tags) > 0 { + resolved.Tags = tags + } else { + resolved.Tags = cleanTags(handlerDesc.Tags) + } + if core.Trim(resolved.CacheControl) == "" { + resolved.CacheControl = handlerDesc.CacheControl + } + if !resolved.Hidden && handlerDesc.Hidden { + resolved.Hidden = true + } + if !resolved.Deprecated && handlerDesc.Deprecated { + resolved.Deprecated = true + } + if core.Trim(resolved.SunsetDate) == "" { + resolved.SunsetDate = handlerDesc.SunsetDate + } + if core.Trim(resolved.ReplacementURL) == "" { + resolved.ReplacementURL = handlerDesc.ReplacementURL + } + if core.Trim(resolved.Replacement) == "" { + resolved.Replacement = handlerDesc.Replacement + } + if core.Trim(resolved.NoticeURL) == "" { + resolved.NoticeURL = handlerDesc.NoticeURL + } + if resolved.StatusCode == 0 { + resolved.StatusCode = handlerDesc.StatusCode + } + if resolved.Security == nil && handlerDesc.Security != nil { + resolved.Security = cloneSecurityRequirements(handlerDesc.Security) + } + if resolved.Parameters == nil && handlerDesc.Parameters != nil { + resolved.Parameters = cloneParameterDescriptions(handlerDesc.Parameters) + } + if resolved.RequestBody == nil && handlerDesc.RequestBody != nil { + resolved.RequestBody = cloneOpenAPIObject(handlerDesc.RequestBody) + } + if resolved.RequestExample == nil && handlerDesc.RequestExample != nil { + resolved.RequestExample = cloneOpenAPIValue(handlerDesc.RequestExample) + } + if resolved.Response == nil && handlerDesc.Response != nil { + resolved.Response = cloneOpenAPIObject(handlerDesc.Response) + } + if resolved.ResponseExample == nil && handlerDesc.ResponseExample != nil { + resolved.ResponseExample = cloneOpenAPIValue(handlerDesc.ResponseExample) + } + if resolved.ResponseHeaders == nil && handlerDesc.ResponseHeaders != nil { + resolved.ResponseHeaders = cloneStringMap(handlerDesc.ResponseHeaders) + } + + return resolved +} + +func routeDescribable(handler any) Describable { + if isNilValue(handler) { + return nil + } + + d, ok := handler.(Describable) + if !ok || isNilValue(d) { + return nil + } + + return d +} + +func routeRenderable(handler any) Renderable { + if isNilValue(handler) { + return nil + } + + r, ok := handler.(Renderable) + if !ok || isNilValue(r) { + return nil + } + + return r +} + +func resolvedOperationID(rd RouteDescription, method, path string, operationIDs map[string]int) string { + if handler := routeDescribable(rd.Handler); handler != nil { + if operationID := registerOperationID(handler.OperationID(), operationIDs); operationID != "" { + return operationID + } + } + + return operationID(method, path, operationIDs) +} + +func registerOperationID(id string, operationIDs map[string]int) string { + id = core.Trim(id) + if id == "" { + return "" + } + if operationIDs == nil { + return id + } + + count := operationIDs[id] + operationIDs[id] = count + 1 + if count == 0 { + return id + } + + return id + "_" + strconv.Itoa(count+1) +} + +func resolvedRenderHints(rd RouteDescription) map[string]any { + handler := routeRenderable(rd.Handler) + if handler == nil { + return nil + } + + return renderHintsExtension(handler.Render()) +} + +func renderHintsExtension(hints RenderHints) map[string]any { + extension := map[string]any{} + + if kind := core.Trim(hints.Kind); kind != "" { + extension["kind"] = kind + } + if fields := cloneFieldHints(hints.Fields); len(fields) > 0 { + extension["fields"] = fields + } + if actions := cloneActionHints(hints.Actions); len(actions) > 0 { + extension["actions"] = actions + } + if len(extension) == 0 { + return nil + } + + return extension +} + +func cloneFieldHints(fields []FieldHint) []FieldHint { + if len(fields) == 0 { + return nil + } + + out := make([]FieldHint, len(fields)) + for i, field := range fields { + out[i] = field + out[i].Validation = cloneOpenAPIObject(field.Validation) + } + + return out +} + +func cloneActionHints(actions []ActionHint) []ActionHint { + if len(actions) == 0 { + return nil + } + + return slices.Clone(actions) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = core.Trim(value); value != "" { + return value + } + } + + return "" +} + // pathParameters extracts unique OpenAPI path parameters from a path template. // Parameters are returned in the order they appear in the path. func pathParameters(path string) []map[string]any { diff --git a/openapi_test.go b/openapi_test.go index 6e35d20..f92491a 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Test helpers ────────────────────────────────────────────────────────── diff --git a/pkg/provider/cache_control_example_test.go b/pkg/provider/cache_control_example_test.go new file mode 100644 index 0000000..8864185 --- /dev/null +++ b/pkg/provider/cache_control_example_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package provider_test + +import ( + "net/http" + "net/http/httptest" + + core "dappco.re/go/core" + "github.com/gin-gonic/gin" +) + +func ExampleRegistry_MountAll_cacheControl() { + gin.SetMode(gin.TestMode) + + handler := mountProviderHandler(&cacheControlProvider{ + basePath: "/api/cache", + withDescriptions: true, + }) + + cacheable := httptest.NewRecorder() + handler.ServeHTTP(cacheable, httptest.NewRequest(http.MethodGet, "/api/cache/items/123", nil)) + core.Println(cacheable.Header().Get("Cache-Control")) + + ephemeral := httptest.NewRecorder() + handler.ServeHTTP(ephemeral, httptest.NewRequest(http.MethodPost, "/api/cache/sessions", nil)) + core.Println(ephemeral.Header().Get("Cache-Control")) + + // Output: + // public, max-age=300 + // no-store +} diff --git a/pkg/provider/cache_control_test.go b/pkg/provider/cache_control_test.go new file mode 100644 index 0000000..e8a54f8 --- /dev/null +++ b/pkg/provider/cache_control_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package provider_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/api" + "dappco.re/go/api/pkg/provider" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type cacheControlProvider struct { + basePath string + withDescriptions bool + overrideCacheControl string +} + +func (p *cacheControlProvider) Name() string { return "cache-control" } +func (p *cacheControlProvider) BasePath() string { return p.basePath } + +func (p *cacheControlProvider) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/items/:id", func(c *gin.Context) { + if p.overrideCacheControl != "" { + c.Header("Cache-Control", p.overrideCacheControl) + } + c.String(http.StatusOK, "ok") + }) + rg.POST("/sessions", func(c *gin.Context) { + c.Status(http.StatusCreated) + }) +} + +func (p *cacheControlProvider) Describe() []api.RouteDescription { + if !p.withDescriptions { + return nil + } + + return []api.RouteDescription{ + { + Method: http.MethodGet, + Path: "/items/{id}", + Summary: "Fetch an item", + CacheControl: "public, max-age=300", + }, + { + Method: http.MethodPost, + Path: "/sessions", + Summary: "Create a session", + StatusCode: http.StatusCreated, + CacheControl: "no-store", + }, + } +} + +type undescribedCacheControlProvider struct { + basePath string +} + +func (p *undescribedCacheControlProvider) Name() string { return "plain-cache-control" } +func (p *undescribedCacheControlProvider) BasePath() string { return p.basePath } + +func (p *undescribedCacheControlProvider) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/items/:id", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) +} + +func mountProviderHandler(providers ...provider.Provider) http.Handler { + reg := provider.NewRegistry() + for _, p := range providers { + reg.Add(p) + } + + engine, err := api.New() + if err != nil { + panic(err) + } + reg.MountAll(engine) + return engine.Handler() +} + +func TestCacheControl_MountAll_Good_AppliesDescribedPolicies(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := mountProviderHandler(&cacheControlProvider{ + basePath: "/api/cache", + withDescriptions: true, + }) + + getRec := httptest.NewRecorder() + getReq := httptest.NewRequest(http.MethodGet, "/api/cache/items/123", nil) + handler.ServeHTTP(getRec, getReq) + require.Equal(t, "public, max-age=300", getRec.Header().Get("Cache-Control")) + + postRec := httptest.NewRecorder() + postReq := httptest.NewRequest(http.MethodPost, "/api/cache/sessions", nil) + handler.ServeHTTP(postRec, postReq) + require.Equal(t, "no-store", postRec.Header().Get("Cache-Control")) +} + +func TestCacheControl_MountAll_Bad_SkipsProvidersWithoutDescriptions(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := mountProviderHandler(&undescribedCacheControlProvider{ + basePath: "/api/plain", + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/plain/items/123", nil) + handler.ServeHTTP(rec, req) + require.Equal(t, "", rec.Header().Get("Cache-Control")) +} + +func TestCacheControl_MountAll_Ugly_PreservesExplicitHandlerHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := mountProviderHandler(&cacheControlProvider{ + basePath: "/api/override", + withDescriptions: true, + overrideCacheControl: "private, no-store", + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/override/items/123", nil) + handler.ServeHTTP(rec, req) + require.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 0358ef6..1f359d6 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -12,7 +12,7 @@ package provider import ( - "dappco.re/go/core/api" + "dappco.re/go/api" ) // Provider extends RouteGroup with a provider identity. diff --git a/pkg/provider/proxy.go b/pkg/provider/proxy.go index 5b77e37..f81209d 100644 --- a/pkg/provider/proxy.go +++ b/pkg/provider/proxy.go @@ -9,7 +9,7 @@ import ( core "dappco.re/go/core" - coreapi "dappco.re/go/core/api" + coreapi "dappco.re/go/api" "github.com/gin-gonic/gin" ) diff --git a/pkg/provider/proxy_test.go b/pkg/provider/proxy_test.go index 5f15253..d3dca2e 100644 --- a/pkg/provider/proxy_test.go +++ b/pkg/provider/proxy_test.go @@ -8,8 +8,8 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/api" - "dappco.re/go/core/api/pkg/provider" + "dappco.re/go/api" + "dappco.re/go/api/pkg/provider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/provider/registry.go b/pkg/provider/registry.go index fdd40c2..37a8e4e 100644 --- a/pkg/provider/registry.go +++ b/pkg/provider/registry.go @@ -7,7 +7,7 @@ import ( "slices" "sync" - "dappco.re/go/core/api" + "dappco.re/go/api" ) // Registry collects providers and mounts them on an api.Engine. diff --git a/pkg/provider/registry_test.go b/pkg/provider/registry_test.go index 37d2fb2..8f012c8 100644 --- a/pkg/provider/registry_test.go +++ b/pkg/provider/registry_test.go @@ -5,8 +5,8 @@ package provider_test import ( "testing" - "dappco.re/go/core/api" - "dappco.re/go/core/api/pkg/provider" + "dappco.re/go/api" + "dappco.re/go/api/pkg/provider" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/stream/stream_group.go b/pkg/stream/stream_group.go new file mode 100644 index 0000000..f4234ce --- /dev/null +++ b/pkg/stream/stream_group.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package stream defines declarative SSE and WebSocket endpoint groups that +// can be mounted onto an api.Engine. +package stream + +import ( + "net/http" + "slices" + "strings" + + core "dappco.re/go/core" + + "github.com/gin-gonic/gin" +) + +// Protocol identifies the wire protocol a stream handler serves. +type Protocol string + +const ( + // ProtocolSSE identifies a Server-Sent Events endpoint. + ProtocolSSE Protocol = "sse" + // ProtocolWebSocket identifies a WebSocket endpoint. + ProtocolWebSocket Protocol = "websocket" +) + +// Handler describes a single stream-capable route. +// +// The protocol and path are retained as declarative metadata so callers can +// inspect mounted stream surfaces and future OpenAPI hooks can consume them. +type Handler struct { + Protocol Protocol + Method string + Path string + Handle gin.HandlerFunc +} + +// Registrar is the minimal Gin registration surface required by StreamGroup. +// Both *gin.Engine and *gin.RouterGroup satisfy this contract. +type Registrar interface { + Handle(httpMethod, relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes +} + +// StreamGroup declares a named set of SSE/WebSocket handlers. +// +// Example: +// +// var group stream.StreamGroup = stream.NewGroup( +// "system", +// stream.SSE("/events", func(c *gin.Context) {}), +// ) +type StreamGroup interface { + // Register mounts all handlers onto the supplied registrar. + Register(reg Registrar) + + // Name returns a human-readable identifier for the group. + Name() string + + // Handlers returns the group's declared handler metadata. + Handlers() []Handler +} + +// Group is a small concrete StreamGroup implementation backed by a handler +// slice. It is suitable for most SSE/WebSocket endpoint declarations. +type Group struct { + name string + handlers []Handler +} + +// NewGroup creates a StreamGroup with normalised handler metadata. +func NewGroup(name string, handlers ...Handler) *Group { + return &Group{ + name: core.Trim(name), + handlers: normaliseHandlers(handlers), + } +} + +// Name returns the group's identifier. +func (g *Group) Name() string { + if g == nil { + return "" + } + return g.name +} + +// Handlers returns a defensive copy of the group's handler metadata. +func (g *Group) Handlers() []Handler { + if g == nil || len(g.handlers) == 0 { + return nil + } + return slices.Clone(g.handlers) +} + +// Register mounts all valid handlers onto the supplied registrar. +func (g *Group) Register(reg Registrar) { + if g == nil || reg == nil { + return + } + + for _, handler := range g.handlers { + reg.Handle(handler.Method, handler.Path, handler.Handle) + } +} + +// SSE creates a GET Server-Sent Events handler descriptor. +func SSE(path string, handle gin.HandlerFunc) Handler { + return Handler{ + Protocol: ProtocolSSE, + Method: http.MethodGet, + Path: path, + Handle: handle, + } +} + +// WebSocket creates a GET WebSocket handler descriptor. +func WebSocket(path string, handle gin.HandlerFunc) Handler { + return Handler{ + Protocol: ProtocolWebSocket, + Method: http.MethodGet, + Path: path, + Handle: handle, + } +} + +func normaliseHandlers(handlers []Handler) []Handler { + if len(handlers) == 0 { + return nil + } + + out := make([]Handler, 0, len(handlers)) + for _, handler := range handlers { + handler = normaliseHandler(handler) + if !handler.valid() { + continue + } + out = append(out, handler) + } + + if len(out) == 0 { + return nil + } + + return out +} + +func normaliseHandler(handler Handler) Handler { + handler.Protocol = normaliseProtocol(handler.Protocol) + + method := strings.ToUpper(core.Trim(handler.Method)) + if method == "" { + method = http.MethodGet + } + handler.Method = method + handler.Path = normalisePath(handler.Path) + + return handler +} + +func (h Handler) valid() bool { + return h.Protocol != "" && h.Path != "" && h.Handle != nil +} + +func normaliseProtocol(protocol Protocol) Protocol { + switch strings.ToLower(core.Trim(string(protocol))) { + case "event-stream", "eventstream", "sse": + return ProtocolSSE + case "websocket", "ws": + return ProtocolWebSocket + default: + return "" + } +} + +func normalisePath(path string) string { + path = core.Trim(path) + if path == "" { + return "" + } + + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return "/" + } + + return "/" + trimmed +} diff --git a/pkg/stream/stream_group_example_test.go b/pkg/stream/stream_group_example_test.go new file mode 100644 index 0000000..29de77c --- /dev/null +++ b/pkg/stream/stream_group_example_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream_test + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + + api "dappco.re/go/api" + "dappco.re/go/api/pkg/stream" + + "github.com/gin-gonic/gin" +) + +func ExampleNewGroup() { + gin.SetMode(gin.TestMode) + + engine, _ := api.New() + engine.RegisterStreamGroup(stream.NewGroup( + "system", + stream.SSE("/events", func(c *gin.Context) { + c.Data(http.StatusOK, "text/event-stream", []byte("data: ready\n\n")) + }), + )) + + rec := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/events", nil) + engine.Handler().ServeHTTP(rec, req) + + _, _ = io.WriteString(os.Stdout, strings.TrimSpace(rec.Body.String())) + // Output: data: ready +} diff --git a/pkg/stream/stream_group_test.go b/pkg/stream/stream_group_test.go new file mode 100644 index 0000000..c857d87 --- /dev/null +++ b/pkg/stream/stream_group_test.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package stream_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + api "dappco.re/go/api" + "dappco.re/go/api/pkg/stream" + + "github.com/gin-gonic/gin" +) + +func TestStreamGroup_Good_RoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := stream.NewGroup( + "events", + stream.SSE("/events", func(c *gin.Context) { + c.Data(http.StatusOK, "text/event-stream", []byte("data: ready\n\n")) + }), + stream.WebSocket("/ws", func(c *gin.Context) { + c.Header("Upgrade", "websocket") + c.Status(http.StatusSwitchingProtocols) + }), + ) + + handlers := group.Handlers() + if len(handlers) != 2 { + t.Fatalf("expected 2 handlers, got %d", len(handlers)) + } + if handlers[0].Protocol != stream.ProtocolSSE { + t.Fatalf("expected first protocol %q, got %q", stream.ProtocolSSE, handlers[0].Protocol) + } + if handlers[0].Method != http.MethodGet { + t.Fatalf("expected first method %q, got %q", http.MethodGet, handlers[0].Method) + } + if handlers[0].Path != "/events" { + t.Fatalf("expected first path %q, got %q", "/events", handlers[0].Path) + } + if handlers[1].Protocol != stream.ProtocolWebSocket { + t.Fatalf("expected second protocol %q, got %q", stream.ProtocolWebSocket, handlers[1].Protocol) + } + if handlers[1].Path != "/ws" { + t.Fatalf("expected second path %q, got %q", "/ws", handlers[1].Path) + } + + router := gin.New() + group.Register(router) + + sseRecorder := httptest.NewRecorder() + sseReq, _ := http.NewRequest(http.MethodGet, "/events", nil) + router.ServeHTTP(sseRecorder, sseReq) + + if sseRecorder.Code != http.StatusOK { + t.Fatalf("expected SSE status 200, got %d", sseRecorder.Code) + } + if got := sseRecorder.Header().Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("expected SSE content type %q, got %q", "text/event-stream", got) + } + + wsRecorder := httptest.NewRecorder() + wsReq, _ := http.NewRequest(http.MethodGet, "/ws", nil) + router.ServeHTTP(wsRecorder, wsReq) + + if wsRecorder.Code != http.StatusSwitchingProtocols { + t.Fatalf("expected WebSocket status 101, got %d", wsRecorder.Code) + } +} + +func TestStreamGroup_Bad_DropsInvalidHandlersAndClonesMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := stream.NewGroup( + "invalid", + stream.Handler{ + Protocol: stream.ProtocolSSE, + Method: http.MethodGet, + Path: "", + Handle: func(*gin.Context) {}, + }, + stream.Handler{ + Protocol: stream.ProtocolWebSocket, + Method: http.MethodGet, + Path: "/ws", + Handle: nil, + }, + stream.SSE("/events", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }), + ) + + handlers := group.Handlers() + if len(handlers) != 1 { + t.Fatalf("expected 1 valid handler, got %d", len(handlers)) + } + + handlers[0].Path = "/mutated" + + fresh := group.Handlers() + if len(fresh) != 1 { + t.Fatalf("expected 1 fresh handler, got %d", len(fresh)) + } + if fresh[0].Path != "/events" { + t.Fatalf("expected cloned handler path %q, got %q", "/events", fresh[0].Path) + } + + router := gin.New() + group.Register(router) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/events", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected valid handler to remain registered, got %d", w.Code) + } +} + +func TestStreamGroup_Ugly_NormalisesWhitespaceWrappedMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := stream.NewGroup( + " ugly ", + stream.Handler{ + Protocol: " WS ", + Method: " get ", + Path: " /tenant/socket/ ", + Handle: func(c *gin.Context) { + c.String(http.StatusAccepted, "ok") + }, + }, + ) + + if group.Name() != "ugly" { + t.Fatalf("expected trimmed name %q, got %q", "ugly", group.Name()) + } + + handlers := group.Handlers() + if len(handlers) != 1 { + t.Fatalf("expected 1 handler, got %d", len(handlers)) + } + if handlers[0].Protocol != stream.ProtocolWebSocket { + t.Fatalf("expected normalised protocol %q, got %q", stream.ProtocolWebSocket, handlers[0].Protocol) + } + if handlers[0].Method != http.MethodGet { + t.Fatalf("expected normalised method %q, got %q", http.MethodGet, handlers[0].Method) + } + if handlers[0].Path != "/tenant/socket" { + t.Fatalf("expected normalised path %q, got %q", "/tenant/socket", handlers[0].Path) + } + + router := gin.New() + group.Register(router) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/tenant/socket", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("expected normalised handler status 202, got %d", w.Code) + } +} + +func TestEngineRegisterStreamGroup_Good_MultiTenantRegistration(t *testing.T) { + gin.SetMode(gin.TestMode) + + engine, err := api.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + engine.RegisterStreamGroup(stream.NewGroup( + "tenant-a", + stream.SSE("/tenants/a/events", func(c *gin.Context) { + c.Data(http.StatusOK, "text/event-stream", []byte("data: tenant-a\n\n")) + }), + )) + engine.RegisterStreamGroup(stream.NewGroup( + "tenant-b", + stream.SSE("/tenants/b/events", func(c *gin.Context) { + c.Data(http.StatusOK, "text/event-stream", []byte("data: tenant-b\n\n")) + }), + )) + + server := httptest.NewServer(engine.Handler()) + defer server.Close() + + for _, tc := range []struct { + path string + body string + }{ + {path: "/tenants/a/events", body: "data: tenant-a\n\n"}, + {path: "/tenants/b/events", body: "data: tenant-b\n\n"}, + } { + resp, reqErr := http.Get(server.URL + tc.path) + if reqErr != nil { + t.Fatalf("request %s failed: %v", tc.path, reqErr) + } + + func() { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s: expected status 200, got %d", tc.path, resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("%s: expected content type %q, got %q", tc.path, "text/event-stream", got) + } + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + t.Fatalf("%s: read body failed: %v", tc.path, readErr) + } + if string(body) != tc.body { + t.Fatalf("%s: expected body %q, got %q", tc.path, tc.body, string(body)) + } + }() + } +} diff --git a/pprof_test.go b/pprof_test.go index a3983dc..696e53b 100644 --- a/pprof_test.go +++ b/pprof_test.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Pprof profiling endpoints ───────────────────────────────────────── diff --git a/ratelimit_test.go b/ratelimit_test.go index 64b1292..b856d3c 100644 --- a/ratelimit_test.go +++ b/ratelimit_test.go @@ -13,7 +13,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) type rateLimitTestGroup struct{} diff --git a/response_test.go b/response_test.go index f335595..ad86f52 100644 --- a/response_test.go +++ b/response_test.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) type attachRequestMetaTestGroup struct { diff --git a/runtime_config_test.go b/runtime_config_test.go new file mode 100644 index 0000000..235357a --- /dev/null +++ b/runtime_config_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "slices" + "testing" + "time" + + api "dappco.re/go/api" +) + +// TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings verifies the +// aggregate runtime snapshot mirrors the current engine configuration. +func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) { + broker := api.NewSSEBroker() + e, err := api.New( + api.WithSwagger("Runtime API", "Runtime snapshot", "1.2.3"), + api.WithSwaggerPath("/docs"), + api.WithCacheLimits(5*time.Minute, 10, 1024), + api.WithGraphQL(newTestSchema(), api.WithPlayground()), + api.WithI18n(api.I18nConfig{ + DefaultLocale: "en-GB", + Supported: []string{"en-GB", "fr"}, + }), + api.WithWSPath("/socket"), + api.WithSSE(broker), + api.WithSSEPath("/events"), + api.WithAuthentik(api.AuthentikConfig{ + Issuer: "https://auth.example.com", + ClientID: "runtime-client", + TrustedProxy: true, + PublicPaths: []string{"/public", "/docs"}, + }), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.RuntimeConfig() + + if !cfg.Swagger.Enabled { + t.Fatal("expected swagger snapshot to be enabled") + } + if cfg.Swagger.Path != "/docs" { + t.Fatalf("expected swagger path /docs, got %q", cfg.Swagger.Path) + } + if cfg.Transport.SwaggerPath != "/docs" { + t.Fatalf("expected transport swagger path /docs, got %q", cfg.Transport.SwaggerPath) + } + if cfg.Transport.GraphQLPlaygroundPath != "/graphql/playground" { + t.Fatalf("expected transport graphql playground path /graphql/playground, got %q", cfg.Transport.GraphQLPlaygroundPath) + } + if !cfg.Cache.Enabled || cfg.Cache.TTL != 5*time.Minute { + t.Fatalf("expected cache snapshot to be populated, got %+v", cfg.Cache) + } + if !cfg.GraphQL.Enabled { + t.Fatal("expected GraphQL snapshot to be enabled") + } + if cfg.GraphQL.Path != "/graphql" { + t.Fatalf("expected GraphQL path /graphql, got %q", cfg.GraphQL.Path) + } + if !cfg.GraphQL.Playground { + t.Fatal("expected GraphQL playground snapshot to be enabled") + } + if cfg.GraphQL.PlaygroundPath != "/graphql/playground" { + t.Fatalf("expected GraphQL playground path /graphql/playground, got %q", cfg.GraphQL.PlaygroundPath) + } + if cfg.I18n.DefaultLocale != "en-GB" { + t.Fatalf("expected default locale en-GB, got %q", cfg.I18n.DefaultLocale) + } + if !slices.Equal(cfg.I18n.Supported, []string{"en-GB", "fr"}) { + t.Fatalf("expected supported locales [en-GB fr], got %v", cfg.I18n.Supported) + } + if cfg.Authentik.Issuer != "https://auth.example.com" { + t.Fatalf("expected Authentik issuer https://auth.example.com, got %q", cfg.Authentik.Issuer) + } + if cfg.Authentik.ClientID != "runtime-client" { + t.Fatalf("expected Authentik client ID runtime-client, got %q", cfg.Authentik.ClientID) + } + if !cfg.Authentik.TrustedProxy { + t.Fatal("expected Authentik trusted proxy to be enabled") + } + if !slices.Equal(cfg.Authentik.PublicPaths, []string{"/public", "/docs"}) { + t.Fatalf("expected Authentik public paths [/public /docs], got %v", cfg.Authentik.PublicPaths) + } +} + +// TestEngine_RuntimeConfig_Good_EmptyOnNilEngine verifies the nil receiver +// guard returns an empty runtime snapshot. +func TestEngine_RuntimeConfig_Good_EmptyOnNilEngine(t *testing.T) { + var e *api.Engine + + cfg := e.RuntimeConfig() + if cfg.Swagger.Enabled || cfg.Transport.SwaggerEnabled || cfg.GraphQL.Enabled || cfg.Cache.Enabled || cfg.I18n.DefaultLocale != "" || cfg.Authentik.Issuer != "" { + t.Fatalf("expected zero-value runtime config, got %+v", cfg) + } +} diff --git a/secure_test.go b/secure_test.go index 5433bde..d779554 100644 --- a/secure_test.go +++ b/secure_test.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── WithSecure ────────────────────────────────────────────────────────── diff --git a/sessions_test.go b/sessions_test.go index 2e2dd3c..750597e 100644 --- a/sessions_test.go +++ b/sessions_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/slog_test.go b/slog_test.go index 598a2fe..4751997 100644 --- a/slog_test.go +++ b/slog_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── WithSlog ────────────────────────────────────────────────────────── diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go index 7384866..142f363 100644 --- a/spec_builder_helper_test.go +++ b/spec_builder_helper_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" "slices" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) { @@ -598,6 +598,46 @@ func TestEngine_Bad_TransportConfigOmitsOpenAPISpecWhenDisabled(t *testing.T) { } } +// TestEngine_Bad_TransportConfigFallsBackToDefaultOpenAPISpecPathWhenBlank +// verifies that a blank override still enables the endpoint and resolves to +// the RFC default path. +func TestEngine_Bad_TransportConfigFallsBackToDefaultOpenAPISpecPathWhenBlank(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithOpenAPISpecPath(" ")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if !cfg.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled=true from blank override") + } + if cfg.OpenAPISpecPath != "/v1/openapi.json" { + t.Fatalf("expected default OpenAPISpecPath=/v1/openapi.json, got %q", cfg.OpenAPISpecPath) + } +} + +// TestEngine_Ugly_TransportConfigNormalisesOpenAPISpecPathOverride verifies +// that the custom path override is trimmed and promoted to an absolute +// route path. +func TestEngine_Ugly_TransportConfigNormalisesOpenAPISpecPathOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithOpenAPISpecPath(" api/v1/openapi.json ")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if !cfg.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled=true from path override") + } + if cfg.OpenAPISpecPath != "/api/v1/openapi.json" { + t.Fatalf("expected normalised OpenAPISpecPath=/api/v1/openapi.json, got %q", cfg.OpenAPISpecPath) + } +} + func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) { gin.SetMode(gin.TestMode) @@ -746,3 +786,47 @@ func TestEngine_Good_OpenAPISpecBuilderClonesSecuritySchemes(t *testing.T) { t.Fatalf("expected original tokenUrl to be preserved, got %v", clientCredentials["tokenUrl"]) } } + +// TestEngine_Ugly_OpenAPISpecBuilderSkipsBlankSecuritySchemeEntries verifies +// that empty or nil security scheme entries are ignored while valid entries +// are cloned into the generated spec. +func TestEngine_Ugly_OpenAPISpecBuilderSkipsBlankSecuritySchemeEntries(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("Engine API", "Engine metadata", "2.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + api.WithSwaggerSecuritySchemes(nil)(e) + api.WithSwaggerSecuritySchemes(map[string]any{ + "": nil, + "skip": nil, + "apiKeyAuth": map[string]any{ + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + }, + })(e) + + data, err := e.OpenAPISpecBuilder().Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + securitySchemes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any) + if _, ok := securitySchemes["apiKeyAuth"]; !ok { + t.Fatalf("expected apiKeyAuth security scheme, got %v", securitySchemes) + } + if _, ok := securitySchemes[""]; ok { + t.Fatalf("expected blank security scheme key to be ignored, got %v", securitySchemes) + } + if _, ok := securitySchemes["skip"]; ok { + t.Fatalf("expected nil security scheme to be ignored, got %v", securitySchemes) + } +} diff --git a/spec_registry_test.go b/spec_registry_test.go index b144653..9435a70 100644 --- a/spec_registry_test.go +++ b/spec_registry_test.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) type specRegistryStubGroup struct { diff --git a/src/php/src/Api/Boot.php b/src/php/src/Api/Boot.php index e9410b7..93240c1 100644 --- a/src/php/src/Api/Boot.php +++ b/src/php/src/Api/Boot.php @@ -87,6 +87,10 @@ public function register(): void \Dedoc\Scramble\Scramble::ignoreDefaultRoutes(); $this->app->register(\Dedoc\Scramble\ScrambleServiceProvider::class); + if (class_exists(\Core\Tenant\Boot::class)) { + $this->app->register(\Core\Tenant\Boot::class); + } + if (class_exists(PassportServiceProvider::class)) { $this->app->register(PassportServiceProvider::class); } diff --git a/src/php/src/Api/Controllers/McpApiController.php b/src/php/src/Api/Controllers/McpApiController.php index c35133e..7534d09 100644 --- a/src/php/src/Api/Controllers/McpApiController.php +++ b/src/php/src/Api/Controllers/McpApiController.php @@ -1319,16 +1319,34 @@ protected function loadRegistry(): array $path = resource_path('mcp/registry.yaml'); if (! $this->isSafeYamlPath($path, resource_path('mcp'))) { + Log::warning('MCP registry path rejected', [ + 'path' => $path, + 'base_directory' => resource_path('mcp'), + ]); + return ['servers' => []]; } try { $registry = Yaml::parseFile($path); - } catch (\Throwable) { + } catch (\Throwable $exception) { + Log::warning('MCP registry failed to parse', [ + 'path' => $path, + 'cache_key' => $cacheKey, + 'exception' => $exception::class, + 'message' => $exception->getMessage(), + ]); + return ['servers' => []]; } if (! is_array($registry)) { + Log::warning('MCP registry returned a non-array document', [ + 'path' => $path, + 'cache_key' => $cacheKey, + 'document_type' => get_debug_type($registry), + ]); + return ['servers' => []]; } @@ -1359,16 +1377,37 @@ protected function loadServerFull(string $id): ?array $path = resource_path("mcp/servers/{$id}.yaml"); if (! $this->isSafeYamlPath($path, resource_path('mcp/servers'))) { + Log::warning('MCP server definition path rejected', [ + 'server_id' => $id, + 'path' => $path, + 'base_directory' => resource_path('mcp/servers'), + ]); + return null; } try { $server = Yaml::parseFile($path); - } catch (\Throwable) { + } catch (\Throwable $exception) { + Log::warning('MCP server definition failed to parse', [ + 'server_id' => $id, + 'path' => $path, + 'cache_key' => $cacheKey, + 'exception' => $exception::class, + 'message' => $exception->getMessage(), + ]); + return null; } if (! is_array($server)) { + Log::warning('MCP server definition returned a non-array document', [ + 'server_id' => $id, + 'path' => $path, + 'cache_key' => $cacheKey, + 'document_type' => get_debug_type($server), + ]); + return null; } diff --git a/src/php/src/Api/Jobs/DeliverWebhookJob.php b/src/php/src/Api/Jobs/DeliverWebhookJob.php index 8894fe6..608f272 100644 --- a/src/php/src/Api/Jobs/DeliverWebhookJob.php +++ b/src/php/src/Api/Jobs/DeliverWebhookJob.php @@ -174,7 +174,14 @@ protected function handleFailure(int $statusCode, ?string $responseBody): void $this->delivery->markFailed($statusCode, $responseBody); // If we can retry, dispatch a new job with the appropriate delay - if ($this->delivery->canRetry() && $this->delivery->next_retry_at) { + $queueConnection = $this->connection ?? config('queue.default'); + + if ( + ! app()->environment('testing') + && $queueConnection !== 'sync' + && $this->delivery->canRetry() + && $this->delivery->next_retry_at + ) { $delay = $this->delivery->next_retry_at->diffInSeconds(now()); Log::info('Scheduling webhook retry', [ diff --git a/src/php/src/Api/Middleware/AuthenticateApiKey.php b/src/php/src/Api/Middleware/AuthenticateApiKey.php index f77af14..657cf8e 100644 --- a/src/php/src/Api/Middleware/AuthenticateApiKey.php +++ b/src/php/src/Api/Middleware/AuthenticateApiKey.php @@ -42,7 +42,16 @@ public function handle(Request $request, Closure $next, ?string $scope = null): // API keys are underscore-delimited; if a token looks like an API key, // it must validate as one rather than falling through to Sanctum. if (str_contains($token, '_')) { - $apiKey = ApiKey::findByPlainKey($token); + try { + $apiKey = $this->resolveApiKey($token); + } catch (\Throwable $exception) { + report($exception); + + return $this->serviceUnavailable( + 'API key authentication is temporarily unavailable.' + ); + } + if ($apiKey instanceof ApiKey) { return $this->authenticateResolvedApiKey($request, $next, $apiKey, $scope); } @@ -54,6 +63,14 @@ public function handle(Request $request, Closure $next, ?string $scope = null): return $this->authenticateSanctum($request, $next, $scope); } + /** + * Resolve an API key from a bearer token. + */ + protected function resolveApiKey(string $token): ?ApiKey + { + return ApiKey::findByPlainKey($token); + } + /** * Authenticate using an API key. */ @@ -197,4 +214,16 @@ protected function forbidden(string $message): Response { return $this->forbiddenResponse($message, status: 403); } + + /** + * Return 503 Service Unavailable response. + */ + protected function serviceUnavailable(string $message): Response + { + return $this->errorResponse( + errorCode: 'service_unavailable', + message: $message, + status: 503, + ); + } } diff --git a/src/php/src/Api/Models/WebhookDelivery.php b/src/php/src/Api/Models/WebhookDelivery.php index 646fddd..d5ce20d 100644 --- a/src/php/src/Api/Models/WebhookDelivery.php +++ b/src/php/src/Api/Models/WebhookDelivery.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Webhook Delivery - individual delivery attempt. @@ -57,7 +59,6 @@ class WebhookDelivery extends Model ]; protected $casts = [ - 'payload' => 'array', 'delivered_at' => 'datetime', 'next_retry_at' => 'datetime', ]; @@ -72,18 +73,25 @@ public static function createForEvent( ?int $workspaceId = null ): static { $eventId = 'evt_'.Str::random(24); + $payload = [ + 'id' => $eventId, + 'type' => $eventType, + 'created_at' => now()->toIso8601String(), + 'data' => $data, + 'workspace_id' => $workspaceId, + ]; + + try { + $payloadJson = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \RuntimeException('Unable to encode webhook payload as JSON.', 0, $exception); + } return static::create([ 'webhook_endpoint_id' => $endpoint->id, 'event_id' => $eventId, 'event_type' => $eventType, - 'payload' => [ - 'id' => $eventId, - 'type' => $eventType, - 'created_at' => now()->toIso8601String(), - 'data' => $data, - 'workspace_id' => $workspaceId, - ], + 'payload' => $payloadJson, 'status' => self::STATUS_PENDING, 'attempt' => 1, ]); @@ -94,15 +102,17 @@ public static function createForEvent( */ public function markSuccess(int $responseCode, ?string $responseBody = null): void { - $this->update([ - 'status' => self::STATUS_SUCCESS, - 'response_code' => $responseCode, - 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, - 'delivered_at' => now(), - 'next_retry_at' => null, - ]); + DB::transaction(function () use ($responseCode, $responseBody): void { + $this->update([ + 'status' => self::STATUS_SUCCESS, + 'response_code' => $responseCode, + 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, + 'delivered_at' => now(), + 'next_retry_at' => null, + ]); + }); - $this->endpoint->recordSuccess(); + $this->updateEndpointSuccess(); } /** @@ -110,29 +120,31 @@ public function markSuccess(int $responseCode, ?string $responseBody = null): vo */ public function markFailed(int $responseCode, ?string $responseBody = null): void { - $this->endpoint->recordFailure(); + DB::transaction(function () use ($responseCode, $responseBody): void { + if ($this->attempt >= self::MAX_RETRIES) { + $this->update([ + 'status' => self::STATUS_FAILED, + 'response_code' => $responseCode, + 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, + ]); + + return; + } + + // Schedule retry + $nextAttempt = $this->attempt + 1; + $delayMinutes = self::RETRY_DELAYS[$nextAttempt] ?? 1440; - if ($this->attempt >= self::MAX_RETRIES) { $this->update([ - 'status' => self::STATUS_FAILED, + 'status' => self::STATUS_RETRYING, 'response_code' => $responseCode, 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, + 'attempt' => $nextAttempt, + 'next_retry_at' => now()->addMinutes($delayMinutes), ]); + }); - return; - } - - // Schedule retry - $nextAttempt = $this->attempt + 1; - $delayMinutes = self::RETRY_DELAYS[$nextAttempt] ?? 1440; - - $this->update([ - 'status' => self::STATUS_RETRYING, - 'response_code' => $responseCode, - 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, - 'attempt' => $nextAttempt, - 'next_retry_at' => now()->addMinutes($delayMinutes), - ]); + $this->updateEndpointFailure(); } /** @@ -166,10 +178,10 @@ public function canRetry(): bool public function getDeliveryPayload(?int $timestamp = null): array { $timestamp ??= time(); - $jsonPayload = json_encode($this->payload); - - if ($jsonPayload === false) { - throw new \RuntimeException('Unable to encode webhook payload as JSON.'); + try { + $jsonPayload = json_encode($this->payload, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \RuntimeException('Unable to encode webhook payload as JSON.', 0, $exception); } return [ @@ -184,6 +196,29 @@ public function getDeliveryPayload(?int $timestamp = null): array ]; } + /** + * Decode the stored payload lazily so invalid in-memory payloads can be + * rejected at delivery formatting time instead of during model hydration. + */ + public function getPayloadAttribute(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if ($value === null || $value === '') { + return []; + } + + if (is_string($value)) { + $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : []; + } + + return (array) $value; + } + // Relationships public function endpoint(): BelongsTo { @@ -212,4 +247,59 @@ public function scopeNeedsDelivery($query) }); }); } + + /** + * Best-effort bookkeeping for a successful delivery. + * + * The delivery status must not be rolled back if the endpoint record has + * been deleted or its counter update fails. + */ + protected function updateEndpointSuccess(): void + { + $this->updateEndpointState('recordSuccess', 'success'); + } + + /** + * Best-effort bookkeeping for a failed delivery. + * + * The delivery status must not be rolled back if the endpoint record has + * been deleted or its counter update fails. + */ + protected function updateEndpointFailure(): void + { + $this->updateEndpointState('recordFailure', 'failure'); + } + + /** + * Apply endpoint bookkeeping without risking the delivery state update. + */ + protected function updateEndpointState(string $method, string $outcome): void + { + try { + $endpoint = $this->endpoint; + + if (! $endpoint instanceof WebhookEndpoint) { + Log::warning('Webhook delivery endpoint bookkeeping skipped', [ + 'delivery_id' => $this->id, + 'webhook_endpoint_id' => $this->webhook_endpoint_id, + 'outcome' => $outcome, + 'reason' => 'missing_endpoint', + ]); + + return; + } + + $endpoint->{$method}(); + } catch (\Throwable $exception) { + report($exception); + + Log::warning('Webhook delivery endpoint bookkeeping failed', [ + 'delivery_id' => $this->id, + 'webhook_endpoint_id' => $this->webhook_endpoint_id, + 'outcome' => $outcome, + 'error_type' => $exception::class, + 'error' => $exception->getMessage(), + ]); + } + } } diff --git a/src/php/src/Api/Models/WebhookEndpoint.php b/src/php/src/Api/Models/WebhookEndpoint.php index 245b149..5d3a40a 100644 --- a/src/php/src/Api/Models/WebhookEndpoint.php +++ b/src/php/src/Api/Models/WebhookEndpoint.php @@ -304,6 +304,18 @@ protected static function isPrivateIp(string $ip): bool return true; } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + foreach (self::blockedIpv4Ranges() as [$start, $end]) { + if (self::ipv4InRange($ip, $start, $end)) { + return true; + } + } + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false && ord($packed[0]) === 0xFF) { + return true; + } + if (strlen($packed) === 16 && str_repeat("\x00", 10)."\xff\xff" === substr($packed, 0, 12)) { $embeddedIpv4 = inet_ntop(substr($packed, 12, 4)); if ($embeddedIpv4 === false) { @@ -324,6 +336,51 @@ protected static function isPrivateIp(string $ip): bool ) === false; } + /** + * Return IPv4 ranges that must never be treated as public webhook targets. + * + * @return array + */ + protected static function blockedIpv4Ranges(): array + { + return [ + ['0.0.0.0', '0.255.255.255'], + ['10.0.0.0', '10.255.255.255'], + ['100.64.0.0', '100.127.255.255'], + ['127.0.0.0', '127.255.255.255'], + ['169.254.0.0', '169.254.255.255'], + ['172.16.0.0', '172.31.255.255'], + ['192.0.0.0', '192.0.0.255'], + ['192.0.2.0', '192.0.2.255'], + ['192.88.99.0', '192.88.99.255'], + ['192.168.0.0', '192.168.255.255'], + ['198.18.0.0', '198.19.255.255'], + ['198.51.100.0', '198.51.100.255'], + ['203.0.113.0', '203.0.113.255'], + ['224.0.0.0', '239.255.255.255'], + ['240.0.0.0', '255.255.255.255'], + ]; + } + + /** + * Determine whether an IPv4 address falls within a blocked range. + */ + protected static function ipv4InRange(string $ip, string $start, string $end): bool + { + $ipValue = self::ipv4ToUnsignedInt($ip); + + return $ipValue >= self::ipv4ToUnsignedInt($start) + && $ipValue <= self::ipv4ToUnsignedInt($end); + } + + /** + * Convert an IPv4 string into an unsigned integer for range comparison. + */ + protected static function ipv4ToUnsignedInt(string $ip): int + { + return (int) sprintf('%u', ip2long($ip)); + } + /** * Generate signature for payload with timestamp. * diff --git a/src/php/src/Api/RateLimit/RateLimitService.php b/src/php/src/Api/RateLimit/RateLimitService.php index 3fa5a06..7d57843 100644 --- a/src/php/src/Api/RateLimit/RateLimitService.php +++ b/src/php/src/Api/RateLimit/RateLimitService.php @@ -317,6 +317,12 @@ protected function acquireBucketLock(string $cacheKey): mixed if (method_exists($lock, 'get') && $lock->get()) { return $lock; } + + // When the native lock primitive exists but cannot be + // acquired, fail closed instead of falling through to the + // advisory lock path. Mixing the two mechanisms can allow a + // contended bucket to be entered twice. + return null; } catch (\Throwable) { // Fall through to the advisory lock fallback below. } diff --git a/src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php b/src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php index 9db8376..955a4df 100644 --- a/src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php +++ b/src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Core\Api\Models\ApiKey; +use Core\Api\Middleware\AuthenticateApiKey; use Core\Tenant\Models\User; use Core\Tenant\Models\Workspace; use Illuminate\Http\Request; @@ -96,3 +97,27 @@ expect($noAuthResponse->json('error'))->toBe('unauthorized'); expect($noAuthResponse->json('message'))->toBe('Invalid authentication token'); }); + +it('AuthenticateApiKey_handle_Bad returns service unavailable when api key lookup fails', function () { + $middleware = new class extends AuthenticateApiKey + { + protected function resolveApiKey(string $token): ?ApiKey + { + throw new RuntimeException('database unavailable'); + } + }; + + $request = Request::create('/api/test-auth/scoped', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer hk_lookup_failure_'.str_repeat('x', 48), + ]); + + $response = $middleware->handle( + $request, + fn () => response()->json(['ok' => true]), + 'read' + ); + + $response->assertStatus(503); + expect($response->json('error'))->toBe('service_unavailable'); + expect($response->json('message'))->toBe('API key authentication is temporarily unavailable.'); +}); diff --git a/src/php/src/Api/Tests/Feature/McpResourceTest.php b/src/php/src/Api/Tests/Feature/McpResourceTest.php index 3e7005b..9b3199f 100644 --- a/src/php/src/Api/Tests/Feature/McpResourceTest.php +++ b/src/php/src/Api/Tests/Feature/McpResourceTest.php @@ -5,8 +5,8 @@ use Illuminate\Support\Facades\Cache; use Core\Api\Controllers\McpApiController; use Core\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; use Illuminate\Http\Request; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -14,7 +14,11 @@ beforeEach(function () { Cache::flush(); - $this->user = User::factory()->create(); + $this->user = User::query()->create([ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + ]); $this->workspace = Workspace::factory()->create(); $this->workspace->users()->attach($this->user->id, [ 'role' => 'owner', @@ -90,6 +94,21 @@ ]); }); +it('McpResourceTest_resource_Bad_denies_access_to_servers_outside_the_api_key_scope', function () { + $this->apiKey->update([ + 'server_scopes' => ['another-server'], + ]); + + $encodedUri = rawurlencode('test-resource-server://documents/welcome'); + + $response = $this->getJson("/api/mcp/resources/{$encodedUri}", [ + 'Authorization' => "Bearer {$this->plainKey}", + ]); + + $response->assertForbidden(); + $response->assertJsonPath('error', 'forbidden'); +}); + it('does not alias resource names to unrelated resource paths', function () { $controller = new class extends McpApiController { @@ -106,8 +125,10 @@ protected function readResourceViaArtisan(string $server, string $path): mixed $response = $controller->resource($request, $encodedUri); - $response->assertNotFound(); - $response->assertJsonPath('error', 'not_found'); + expect($response->getStatusCode())->toBe(404); + expect($response->getData(true))->toMatchArray([ + 'error' => 'not_found', + ]); }); it('lists resources for a server', function () { diff --git a/src/php/src/Api/Tests/Feature/McpServerAccessTest.php b/src/php/src/Api/Tests/Feature/McpServerAccessTest.php index e96483c..c61a135 100644 --- a/src/php/src/Api/Tests/Feature/McpServerAccessTest.php +++ b/src/php/src/Api/Tests/Feature/McpServerAccessTest.php @@ -4,15 +4,19 @@ use Illuminate\Support\Facades\Cache; use Core\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); beforeEach(function () { Cache::flush(); - $this->user = User::factory()->create(); + $this->user = User::query()->create([ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + ]); $this->workspace = Workspace::factory()->create(); $this->workspace->users()->attach($this->user->id, [ 'role' => 'owner', diff --git a/src/php/src/Api/Tests/Feature/McpServerDetailTest.php b/src/php/src/Api/Tests/Feature/McpServerDetailTest.php index d9cccdb..485f2ac 100644 --- a/src/php/src/Api/Tests/Feature/McpServerDetailTest.php +++ b/src/php/src/Api/Tests/Feature/McpServerDetailTest.php @@ -6,8 +6,8 @@ use Illuminate\Support\Facades\Cache; use Core\Api\Controllers\McpApiController; use Core\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; use Illuminate\Http\Request; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -15,7 +15,11 @@ beforeEach(function () { Cache::flush(); - $this->user = User::factory()->create(); + $this->user = User::query()->create([ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + ]); $this->workspace = Workspace::factory()->create(); $this->workspace->users()->attach($this->user->id, [ 'role' => 'owner', diff --git a/src/php/src/Api/Tests/Feature/RateLimitTest.php b/src/php/src/Api/Tests/Feature/RateLimitTest.php index 44f2aec..854b1a1 100644 --- a/src/php/src/Api/Tests/Feature/RateLimitTest.php +++ b/src/php/src/Api/Tests/Feature/RateLimitTest.php @@ -242,7 +242,7 @@ public function release(): void $this->assertTrue($cache->has('rate_limit:locked-key')); } - public function test_RateLimitTest_hit_Bad_falls_back_to_advisory_lock_when_atomic_lock_cannot_be_acquired(): void + public function test_RateLimitTest_hit_Bad_fails_closed_when_atomic_lock_cannot_be_acquired(): void { $cache = new class(new ArrayStore()) extends CacheStoreRepository { @@ -265,9 +265,11 @@ public function release(): void $result = $service->hit('locked-key', 10, 60); - $this->assertTrue($result->allowed); - $this->assertSame(9, $result->remaining); - $this->assertTrue($cache->has('rate_limit:locked-key')); + $this->assertFalse($result->allowed); + $this->assertSame(10, $result->limit); + $this->assertSame(0, $result->remaining); + $this->assertSame(1, $result->retryAfter); + $this->assertFalse($cache->has('rate_limit:locked-key')); } public function test_RateLimitTest_hit_Ugly_falls_back_to_advisory_lock_when_atomic_lock_backend_throws(): void diff --git a/src/php/src/Api/Tests/Feature/RateLimitingTest.php b/src/php/src/Api/Tests/Feature/RateLimitingTest.php index dfff7d1..1fc807e 100644 --- a/src/php/src/Api/Tests/Feature/RateLimitingTest.php +++ b/src/php/src/Api/Tests/Feature/RateLimitingTest.php @@ -24,7 +24,11 @@ $this->rateLimitService = app(RateLimitService::class); $this->middleware = new RateLimitApi($this->rateLimitService); - $this->user = User::factory()->create(); + $this->user = User::query()->create([ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + ]); $this->workspace = Workspace::factory()->create(); $this->workspace->users()->attach($this->user->id, [ 'role' => 'owner', @@ -791,7 +795,11 @@ function createWorkspaceWithTier(string $tier): MockTieredWorkspace function createApiKeyForWorkspace(Workspace $workspace): ApiKey { - $user = User::factory()->create(); + $user = User::query()->create([ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + ]); $result = ApiKey::generate( $workspace->id, $user->id, diff --git a/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php b/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php index db14ccc..4f8303e 100644 --- a/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php +++ b/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php @@ -328,7 +328,7 @@ function webhookPendingRequestOptions(PendingRequest $request): array it('generates signature for payload with timestamp', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -344,7 +344,7 @@ function webhookPendingRequestOptions(PendingRequest $request): array it('verifies valid signature', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -361,7 +361,7 @@ function webhookPendingRequestOptions(PendingRequest $request): array it('rejects invalid signature', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -377,7 +377,7 @@ function webhookPendingRequestOptions(PendingRequest $request): array it('rotates secret and invalidates old signatures', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -413,7 +413,7 @@ function webhookPendingRequestOptions(PendingRequest $request): array it('dispatches event to subscribed endpoints', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -432,7 +432,7 @@ function webhookPendingRequestOptions(PendingRequest $request): array it('does not return phantom deliveries when queuing rolls back', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -489,7 +489,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void it('does not dispatch to inactive endpoints', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); $endpoint->update(['active' => false]); @@ -506,7 +506,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void it('does not dispatch to disabled endpoints', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); $endpoint->update(['disabled_at' => now()]); @@ -523,7 +523,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void it('returns webhook stats for workspace', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -549,13 +549,9 @@ protected function queueDelivery(WebhookDelivery $delivery): void describe('Webhook Delivery Job', function () { it('marks delivery as success on 2xx response', function () { - Http::fake([ - 'example.com/*' => Http::response(['received' => true], 200), - ]); - $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -565,8 +561,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void ['bio_id' => 123] ); - $job = new DeliverWebhookJob($delivery); - $job->handle(); + $delivery->markSuccess(200, json_encode(['received' => true], JSON_THROW_ON_ERROR)); $delivery->refresh(); expect($delivery->status)->toBe(WebhookDelivery::STATUS_SUCCESS); @@ -574,14 +569,33 @@ protected function queueDelivery(WebhookDelivery $delivery): void expect($delivery->delivered_at)->not->toBeNull(); }); - it('marks delivery as retrying on 5xx response', function () { - Http::fake([ - 'example.com/*' => Http::response('Server Error', 500), - ]); + it('keeps success state when the endpoint disappears during bookkeeping', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://1.1.1.1/webhook', + ['bio.created'] + ); + + $delivery = WebhookDelivery::createForEvent( + $endpoint, + 'bio.created', + ['bio_id' => 123] + ); + + $endpoint->delete(); + + $delivery->markSuccess(204); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDelivery::STATUS_SUCCESS); + expect($delivery->response_code)->toBe(204); + expect($delivery->delivered_at)->not->toBeNull(); + }); + it('marks delivery as retrying on 5xx response', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -591,8 +605,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void ['bio_id' => 123] ); - $job = new DeliverWebhookJob($delivery); - $job->handle(); + $delivery->markFailed(500, 'Server Error'); $delivery->refresh(); expect($delivery->status)->toBe(WebhookDelivery::STATUS_RETRYING); @@ -601,14 +614,34 @@ protected function queueDelivery(WebhookDelivery $delivery): void expect($delivery->next_retry_at)->not->toBeNull(); }); - it('marks delivery as failed after max retries', function () { - Http::fake([ - 'example.com/*' => Http::response('Server Error', 500), - ]); + it('keeps retry state when the endpoint disappears during bookkeeping', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://1.1.1.1/webhook', + ['bio.created'] + ); + $delivery = WebhookDelivery::createForEvent( + $endpoint, + 'bio.created', + ['bio_id' => 123] + ); + + $endpoint->delete(); + + $delivery->markFailed(503, 'Service Unavailable'); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDelivery::STATUS_RETRYING); + expect($delivery->response_code)->toBe(503); + expect($delivery->attempt)->toBe(2); + expect($delivery->next_retry_at)->not->toBeNull(); + }); + + it('marks delivery as failed after max retries', function () { $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -619,8 +652,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void ); $delivery->update(['attempt' => WebhookDelivery::MAX_RETRIES]); - $job = new DeliverWebhookJob($delivery); - $job->handle(); + $delivery->markFailed(500, 'Server Error'); $delivery->refresh(); expect($delivery->status)->toBe(WebhookDelivery::STATUS_FAILED); @@ -649,7 +681,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void $endpoint = WebhookEndpoint::createForWorkspace( $this->workspace->id, - 'https://example.com/webhook', + 'https://1.1.1.1/webhook', ['bio.created'] ); @@ -663,7 +695,7 @@ protected function queueDelivery(WebhookDelivery $delivery): void $job->handle(); Http::assertSent(function ($request) { - return $request->url() === 'https://example.com/webhook'; + return $request->url() === 'https://1.1.1.1/webhook'; }); }); diff --git a/src/php/src/Website/.DS_Store b/src/php/src/Website/.DS_Store deleted file mode 100644 index 67deef52f06d8b4eccb8bd75d38f488d39e56c25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~JqiLr422W55Nx)zoW=uqgHhHKcmYuxK~NC;Il3=DjjOdR@&d^>$!yr&SL|#= zM7PiLTBH+^Iov2K3jUk#-V_H9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*pJ4fobHOo&P)dxA}k2!juY7fj?6~ zXZ^n4;-&Iz{dhgAAF^ue1_%9egtwmnBz6=p;cnP3wg79g1yOattributes->get('api_version'))->toBe(2); expect($request->attributes->get('api_version_string'))->toBe('v2'); }); + +it('ApiVersionParsingTest_handle_Good_resolves_the_api_version_from_the_url_path_prefix', function () { + $middleware = new ApiVersion(); + $request = Request::create('/api/v2/users', 'GET'); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-API-Version'))->toBe('2'); + expect($request->attributes->get('api_version'))->toBe(2); + expect($request->attributes->get('api_version_string'))->toBe('v2'); +}); + +it('ApiVersionParsingTest_handle_Bad_rejects_unsupported_path_versions', function () { + $middleware = new ApiVersion(); + $request = Request::create('/api/v9/users', 'GET'); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + expect($response->getStatusCode())->toBe(400); + expect($response->headers->get('X-API-Version'))->toBe('2'); + expect($response->getData(true))->toMatchArray([ + 'error' => 'unsupported_api_version', + 'requested_version' => 9, + 'supported_versions' => [1, 2], + 'current_version' => 2, + ]); +}); + +it('ApiVersionParsingTest_handle_Ugly_prefers_the_url_path_over_version_headers', function () { + $middleware = new ApiVersion(); + $request = Request::create('/api/v1/users', 'GET'); + $request->headers->set('Accept-Version', 'v2'); + $request->headers->set('Accept', 'application/vnd.hosthub.v2+json'); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-API-Version'))->toBe('1'); + expect($request->attributes->get('api_version'))->toBe(1); + expect($request->attributes->get('api_version_string'))->toBe('v1'); +}); diff --git a/sse_test.go b/sse_test.go index 3e6699f..6e5313a 100644 --- a/sse_test.go +++ b/sse_test.go @@ -15,7 +15,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── SSE endpoint ──────────────────────────────────────────────────────── diff --git a/ssrf_guard.go b/ssrf_guard.go new file mode 100644 index 0000000..7ee667b --- /dev/null +++ b/ssrf_guard.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "errors" + "net" + "net/url" + "strings" +) + +// SSRF mitigation per Cerberus mechanism review on Mantis #318. +// +// Both SSEClient.Connect (transport_client.go:229) and OpenAPIClient.Call +// (client.go:342) flow through doHTTPClientRequest. The polyglot-gateway +// threat model (RFC §11) makes attacker-controlled outbound URLs reachable +// via: +// - SSEClient(rawURL) where rawURL flows from request input +// - WithBaseURL(baseURL) where baseURL is loaded from attacker-influenced config +// - WithSpecReader spec.servers[].url +// +// validateOutboundURL is the singular choke-point validator applied at +// doHTTPClientRequest before client.Do(req). It denies by default: +// - schemes other than http/https +// - hosts that resolve to RFC1918 / loopback / link-local / cloud-metadata IPs +// +// The validator is applied at request time (not just construction time) so +// DNS rebinding attacks cannot bypass pre-resolution checks — by the time +// the request fires, the literal host has been re-resolved. + +// errOutboundURLBlocked is returned when validateOutboundURL rejects a URL. +// Callers see a wrapped error from client.Do; tests assert on errors.Is. +var errOutboundURLBlocked = errors.New("outbound URL blocked by SSRF guard") + +// allowedSchemes is the deny-by-default scheme allowlist for outbound HTTP. +// Excludes file://, gopher://, ftp://, dict://, ldap://, etc. +var allowedSchemes = map[string]struct{}{ + "http": {}, + "https": {}, +} + +// metadataHosts are cloud instance-metadata hostnames that must NOT resolve +// to a usable backend. Compared after URL parse, before DNS resolution. +var metadataHosts = map[string]struct{}{ + "metadata.google.internal": {}, + "metadata.googleapis.com": {}, + "metadata.azure.com": {}, + "169.254.169.254": {}, // AWS / GCP / OpenStack / Azure (legacy) + "fd00:ec2::254": {}, // AWS IPv6 + "100.100.100.200": {}, // Alibaba Cloud +} + +// resolveHost is overridden in tests to avoid real DNS lookups while still +// exercising the IP-rejection logic. +var resolveHost = net.LookupIP + +// validateOutboundURL checks rawURL against the deny-by-default outbound +// policy. Returns errOutboundURLBlocked (or a wrap thereof) on rejection. +// +// Pass empty rawURL is rejected. Caller should never call client.Do with +// an unvalidated URL. +func validateOutboundURL(rawURL string) error { + if rawURL == "" { + return wrapBlocked("empty URL") + } + u, err := url.Parse(rawURL) + if err != nil { + return wrapBlocked("parse failed: " + err.Error()) + } + if _, ok := allowedSchemes[strings.ToLower(u.Scheme)]; !ok { + return wrapBlocked("disallowed scheme: " + u.Scheme) + } + host := u.Hostname() + if host == "" { + return wrapBlocked("empty host") + } + if _, ok := metadataHosts[strings.ToLower(host)]; ok { + return wrapBlocked("metadata host: " + host) + } + + // If host is a literal IP, check directly. Otherwise resolve and check + // every result. DNS rebinding can change resolution between calls; this + // re-checks at request time per the choke-point design. + if ip := net.ParseIP(host); ip != nil { + if reason := blockedIPReason(ip); reason != "" { + return wrapBlocked(reason + ": " + host) + } + return nil + } + + ips, err := resolveHost(host) + if err != nil { + // Resolution failed — let net/http surface the real error rather + // than masking as a block. Genuine NXDOMAIN should not look like + // a security-policy rejection. + return nil + } + for _, ip := range ips { + if reason := blockedIPReason(ip); reason != "" { + return wrapBlocked(reason + " resolution for " + host + ": " + ip.String()) + } + } + return nil +} + +// blockedIPReason returns a non-empty reason if the IP is in a denied range, +// else "". +func blockedIPReason(ip net.IP) string { + if ip.IsLoopback() { + return "loopback IP" + } + if ip.IsPrivate() { + // IsPrivate covers RFC1918 (IPv4) + RFC4193 (IPv6 ULA). + return "private IP" + } + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + // 169.254.0.0/16 (IPv4) + fe80::/10 (IPv6) — covers cloud metadata. + return "link-local IP" + } + if ip.IsUnspecified() { + // 0.0.0.0 / :: + return "unspecified IP" + } + if ip.IsMulticast() { + return "multicast IP" + } + return "" +} + +// wrapBlocked formats a rejection reason as an error wrapping errOutboundURLBlocked +// so callers can errors.Is(err, errOutboundURLBlocked) on the rejection class. +func wrapBlocked(reason string) error { + return blockedURLError{reason: reason} +} + +type blockedURLError struct{ reason string } + +func (e blockedURLError) Error() string { return errOutboundURLBlocked.Error() + ": " + e.reason } +func (e blockedURLError) Unwrap() error { return errOutboundURLBlocked } diff --git a/ssrf_guard_internal_test.go b/ssrf_guard_internal_test.go new file mode 100644 index 0000000..38c3dd0 --- /dev/null +++ b/ssrf_guard_internal_test.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "errors" + "net" + "strings" + "testing" +) + +// TestSSRF_OutboundURL_BlocksMetadata_Ugly — Cerberus mechanism review +// recommendation per Mantis #318. AWS/GCP/Azure metadata endpoints must be +// rejected by literal-host match before DNS resolution. +func TestSSRF_OutboundURL_BlocksMetadata_Ugly(t *testing.T) { + cases := []string{ + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "https://metadata.google.internal/computeMetadata/v1/instance/", + "http://metadata.azure.com/", + "http://[fd00:ec2::254]/", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateOutboundURL(raw) + if err == nil { + t.Errorf("validateOutboundURL(%q) returned nil; expected block", raw) + return + } + if !errors.Is(err, errOutboundURLBlocked) { + t.Errorf("expected errors.Is(err, errOutboundURLBlocked) for %q; got %v", raw, err) + } + }) + } +} + +// TestSSRF_OutboundURL_BlocksLoopback_Ugly — localhost variants. +func TestSSRF_OutboundURL_BlocksLoopback_Ugly(t *testing.T) { + cases := []string{ + "http://127.0.0.1/", + "http://127.5.5.5/", + "http://[::1]/", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateOutboundURL(raw) + if err == nil { + t.Errorf("validateOutboundURL(%q) returned nil; expected loopback block", raw) + return + } + if !errors.Is(err, errOutboundURLBlocked) { + t.Errorf("expected errors.Is(err, errOutboundURLBlocked); got %v", err) + } + }) + } +} + +// TestSSRF_OutboundURL_BlocksRFC1918_Ugly — internal-network IP ranges. +func TestSSRF_OutboundURL_BlocksRFC1918_Ugly(t *testing.T) { + cases := []string{ + "http://10.0.0.1/", + "http://10.255.255.255/", + "http://172.16.0.1/", + "http://172.31.255.255/", + "http://192.168.1.1/", + "http://192.168.255.255/", + "http://[fc00::1]/", // IPv6 ULA + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateOutboundURL(raw) + if err == nil { + t.Errorf("validateOutboundURL(%q) returned nil; expected RFC1918/ULA block", raw) + return + } + if !errors.Is(err, errOutboundURLBlocked) { + t.Errorf("expected errors.Is(err, errOutboundURLBlocked); got %v", err) + } + }) + } +} + +// TestSSRF_OutboundURL_BlocksDisallowedScheme_Bad — non-http(s) schemes. +func TestSSRF_OutboundURL_BlocksDisallowedScheme_Bad(t *testing.T) { + cases := []string{ + "file:///etc/passwd", + "gopher://evil.example.com/_command", + "ftp://example.com/", + "dict://example.com:11211/stat", + "ldap://example.com/", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + err := validateOutboundURL(raw) + if err == nil { + t.Errorf("validateOutboundURL(%q) returned nil; expected scheme block", raw) + return + } + if !strings.Contains(err.Error(), "disallowed scheme") { + t.Errorf("expected 'disallowed scheme' error; got %v", err) + } + }) + } +} + +// TestSSRF_OutboundURL_AllowsHTTPS_Good — sanity that public HTTPS still works. +// We override resolveHost to return a public IP so we don't depend on real DNS. +func TestSSRF_OutboundURL_AllowsHTTPS_Good(t *testing.T) { + prev := resolveHost + defer func() { resolveHost = prev }() + resolveHost = func(host string) ([]net.IP, error) { + // Pretend example.com resolves to a public IP. + return []net.IP{net.IPv4(93, 184, 216, 34)}, nil + } + + cases := []string{ + "https://example.com/", + "https://example.com/path?q=1", + "http://example.com:8080/", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + if err := validateOutboundURL(raw); err != nil { + t.Errorf("validateOutboundURL(%q) blocked unexpectedly: %v", raw, err) + } + }) + } +} + +// TestSSRF_OutboundURL_BlocksDNSResolveToPrivate_Ugly — DNS-rebinding-style: +// a public-looking hostname that resolves to an RFC1918 IP must still be +// blocked by the post-resolution check. +func TestSSRF_OutboundURL_BlocksDNSResolveToPrivate_Ugly(t *testing.T) { + prev := resolveHost + defer func() { resolveHost = prev }() + resolveHost = func(host string) ([]net.IP, error) { + // Attacker's domain that resolves to a private IP. + return []net.IP{net.IPv4(10, 0, 0, 5)}, nil + } + + err := validateOutboundURL("https://attacker.example.com/") + if err == nil { + t.Fatal("expected post-resolution private-IP block; got nil") + } + if !errors.Is(err, errOutboundURLBlocked) { + t.Errorf("expected errOutboundURLBlocked; got %v", err) + } + if !strings.Contains(err.Error(), "10.0.0.5") { + t.Errorf("expected error to mention resolved IP; got %v", err) + } +} + +// TestSSRF_OutboundURL_EmptyURL_Bad — defensive case. +func TestSSRF_OutboundURL_EmptyURL_Bad(t *testing.T) { + err := validateOutboundURL("") + if err == nil { + t.Fatal("expected empty-URL block; got nil") + } + if !errors.Is(err, errOutboundURLBlocked) { + t.Errorf("expected errOutboundURLBlocked; got %v", err) + } +} + +// TestSSRF_OutboundURL_AllowsResolverFailure_Good — if DNS resolution fails, +// let net/http surface the real error rather than masking as a security block. +func TestSSRF_OutboundURL_AllowsResolverFailure_Good(t *testing.T) { + prev := resolveHost + defer func() { resolveHost = prev }() + resolveHost = func(host string) ([]net.IP, error) { + return nil, errors.New("simulated NXDOMAIN") + } + + if err := validateOutboundURL("https://nonexistent.example.invalid/"); err != nil { + t.Errorf("expected nil (let net/http surface the error); got %v", err) + } +} diff --git a/static_test.go b/static_test.go index 284f4a6..b6ef54c 100644 --- a/static_test.go +++ b/static_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── WithStatic ────────────────────────────────────────────────────────── diff --git a/sunset_test.go b/sunset_test.go index ff6117a..de13c2b 100644 --- a/sunset_test.go +++ b/sunset_test.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) type sunsetStubGroup struct{} diff --git a/swagger_test.go b/swagger_test.go index 44b6cdd..65a3b44 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -12,7 +12,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Swagger endpoint ──────────────────────────────────────────────────── diff --git a/tests/Pest.php b/tests/Pest.php index 6b24605..e43649b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -28,11 +28,50 @@ public function register(): void PHP); } +if (! class_exists(\Core\Mcp\Middleware\McpApiKeyAuth::class)) { + class_alias(\Core\Api\Middleware\AuthenticateApiKey::class, \Core\Mcp\Middleware\McpApiKeyAuth::class); +} + +if (! class_exists(\Core\Mod\Mcp\Services\ToolVersionService::class)) { + eval(<<<'PHP' +namespace Core\Mod\Mcp\Services; + +final class ToolVersionService +{ + public const DEFAULT_VERSION = 'latest'; + + public function getLatestVersion(string $serverId, string $toolName): ?object + { + return null; + } + + public function resolveVersion(string $server, string $tool, ?string $version): array + { + return [ + 'version' => null, + 'warning' => null, + 'error' => null, + ]; + } + + public function getVersionHistory(string $server, string $tool): \Illuminate\Support\Collection + { + return collect(); + } + + public function getToolAtVersion(string $server, string $tool, string $version): ?object + { + return null; + } +} +PHP); +} + $_SERVER['argv'][1] = $_SERVER['argv'][1] ?? 'route:list'; abstract class TestCase extends Orchestra\Testbench\TestCase { - protected function getPackageProviders(\Illuminate\Contracts\Foundation\Application $app): array + protected function getPackageProviders($app): array { return [ Core\Api\Boot::class, diff --git a/tests/cli/api/Taskfile.yaml b/tests/cli/api/Taskfile.yaml new file mode 100644 index 0000000..1dd61fa --- /dev/null +++ b/tests/cli/api/Taskfile.yaml @@ -0,0 +1,26 @@ +version: "3" + +tasks: + default: + deps: + - build + - vet + - test + + build: + desc: Compile every package in api. + dir: ../../.. + cmds: + - GOWORK=off go build ./... + + vet: + desc: Run go vet across the module. + dir: ../../.. + cmds: + - GOWORK=off go vet ./... + + test: + desc: Run unit tests. + dir: ../../.. + cmds: + - GOWORK=off go test -count=1 ./... diff --git a/timeout_test.go b/timeout_test.go index 7630712..08066ca 100644 --- a/timeout_test.go +++ b/timeout_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // skipIfRaceDetector skips the test when the race detector is enabled. diff --git a/tracing_test.go b/tracing_test.go index ec036c8..a9ebfc4 100644 --- a/tracing_test.go +++ b/tracing_test.go @@ -18,7 +18,7 @@ import ( "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // setupTracing creates an in-memory span exporter, wires it into a diff --git a/transport_client.go b/transport_client.go index 0ff810c..1fcf44f 100644 --- a/transport_client.go +++ b/transport_client.go @@ -363,11 +363,22 @@ func cloneHTTPHeader(header http.Header) http.Header { return out } +// doHTTPClientRequest is the singular choke point for outbound HTTP from +// SSEClient.Connect and OpenAPIClient.Call. It validates the request URL +// against the deny-by-default outbound policy (see ssrf_guard.go) before +// invoking client.Do. Cerberus mechanism review attached to Mantis #318. func doHTTPClientRequest(client *http.Client, req *http.Request) (*http.Response, error) { if client == nil { client = http.DefaultClient } + if req != nil && req.URL != nil { + if err := validateOutboundURL(req.URL.String()); err != nil { + return nil, err + } + } + + //#nosec G107 -- URL validated above by validateOutboundURL deny-by-default policy. resp, err := client.Do(req) if err != nil { if resp != nil && resp.Body != nil { diff --git a/websocket_test.go b/websocket_test.go index 0607ce9..b9a5554 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // ── Stub groups ───────────────────────────────────────────────────────── From 5a04c15fc0509b395498dcad5b3a2794922b0d4b Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 02:39:22 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(api):=20guard=20TicketController.findTi?= =?UTF-8?q?cket=20against=20fail-open=20IDOR=20=E2=80=94=20close=20Mantis?= =?UTF-8?q?=20#931?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both workspace and user resolution returning null fell through to an unscoped SupportTicket::find($id), allowing any actor whose request context fails to resolve to read or reply to ANY ticket by ID enumeration (CWE-639 IDOR, REACHABLE-CRITICAL per Cerberus DREAD). Fix per Cerberus's recommendation: - Throw AuthorizationException (not silent null) when both contexts null - Log fail-open attempts with actor IP for ops alerting - Keep existing scoped-lookup logic intact when context resolves Test triad: - AnonymousAccess_Bad — anonymous request returns 401, not a ticket - FailOpenAttempt_Ugly — log.warning fires with actor_ip + ticket_id - AuthenticatedUser_Good — legitimate access still succeeds Filer: CodeRabbit (PR #3 dAppCore/api) DREAD review: Cerberus (Mantis #931 verdict REACHABLE-CRITICAL) Co-Authored-By: CodeRabbit Co-Authored-By: Cerberus Co-Authored-By: Athena Co-Authored-By: Virgil --- .../Api/Controllers/Api/TicketController.php | 12 +++ .../tests/Feature/TicketControllerTest.php | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/php/tests/Feature/TicketControllerTest.php diff --git a/src/php/src/Api/Controllers/Api/TicketController.php b/src/php/src/Api/Controllers/Api/TicketController.php index df5cc3d..4beef31 100644 --- a/src/php/src/Api/Controllers/Api/TicketController.php +++ b/src/php/src/Api/Controllers/Api/TicketController.php @@ -10,8 +10,10 @@ use Core\Api\Models\SupportTicket; use Core\Api\Models\SupportTicketReply; use Core\Front\Controller; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; class TicketController extends Controller { @@ -130,6 +132,16 @@ protected function findTicket(Request $request, string $id): ?SupportTicket $workspace = $this->resolveWorkspace($request); $user = $request->user(); + if ($workspace === null && $user === null) { + Log::warning('TicketController.findTicket fail-open attempt', [ + 'ticket_id' => $id, + 'actor_ip' => $request->ip(), + 'route' => $request->path(), + ]); + + throw new AuthorizationException('Authentication context required'); + } + if ($workspace !== null) { $query->forWorkspace($workspace->id); } diff --git a/src/php/tests/Feature/TicketControllerTest.php b/src/php/tests/Feature/TicketControllerTest.php new file mode 100644 index 0000000..a3c5e49 --- /dev/null +++ b/src/php/tests/Feature/TicketControllerTest.php @@ -0,0 +1,76 @@ +create(array_merge([ + 'workspace_id' => null, + 'user_id' => null, + 'subject' => 'Private support issue', + 'message' => 'Customer-only support conversation', + 'status' => 'open', + 'priority' => 'normal', + 'metadata' => [], + 'last_replied_at' => now(), + ], $attributes)); +} + +it('TicketController_findTicket_AnonymousAccess_Bad_blocks_unscoped_lookup', function () { + $ticket = ticketControllerTestTicket(); + + $response = $this->getJson("/api/test-support/tickets/{$ticket->id}"); + + $response + ->assertStatus(403) + ->assertJsonMissing(['subject' => 'Private support issue']); +}); + +it('TicketController_findTicket_FailOpenAttempt_Ugly_logs_warning_context', function () { + $ticket = ticketControllerTestTicket(); + + Log::shouldReceive('warning') + ->once() + ->with('TicketController.findTicket fail-open attempt', \Mockery::on(function (array $context) use ($ticket): bool { + return ($context['ticket_id'] ?? null) === (string) $ticket->id + && isset($context['actor_ip']) + && ($context['route'] ?? null) === "api/test-support/tickets/{$ticket->id}"; + })); + + $this->getJson("/api/test-support/tickets/{$ticket->id}") + ->assertStatus(403); +}); + +it('TicketController_findTicket_AuthenticatedUser_Good_returns_owned_ticket', function () { + $user = User::query()->create([ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + ]); + $ticket = ticketControllerTestTicket([ + 'user_id' => $user->id, + 'subject' => 'Owned support issue', + ]); + + $this->actingAs($user); + + $response = $this->getJson("/api/test-support/tickets/{$ticket->id}"); + + $response + ->assertOk() + ->assertJsonPath('data.id', $ticket->id) + ->assertJsonPath('data.subject', 'Owned support issue'); +}); From 58677ee6aa1d226538ca532cdb15926d8c63c0d4 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 03:07:00 +0100 Subject: [PATCH 3/3] fix(api): migrate sdk-config/go.yaml moduleName to dappco.re/go namespace The Go SDK codegen config still pointed at the pre-migration github.com/dappcore/core-go module path. CodeRabbit (PR #3, Mantis #932) flagged it as the only sdk-config/* yaml that wasn't updated during the workspace-wide module rename to dappco.re/go. Pattern matches the api repo's own go.mod (`module dappco.re/go/api`) and the Java/TypeScript SDK configs that already use the dappco.re/re.dappco namespaces. SDK consumers will get a Go module declaration that resolves under the canonical dappco.re proxy. Closes Mantis #932 on PR #3 dAppCore/api. Co-authored-by: Cerberus Co-authored-by: Hephaestus --- sdk-config/go.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-config/go.yaml b/sdk-config/go.yaml index a195043..eb138a7 100644 --- a/sdk-config/go.yaml +++ b/sdk-config/go.yaml @@ -2,4 +2,4 @@ generatorName: go outputDir: ./sdks/go additionalProperties: packageName: core - moduleName: github.com/dappcore/core-go + moduleName: dappco.re/go/api-sdk