diff --git a/api.go b/api.go index 42d7f78..e26c271 100644 --- a/api.go +++ b/api.go @@ -44,6 +44,7 @@ type Engine struct { cacheMaxEntries int cacheMaxBytes int wsHandler http.Handler + wsGinHandler gin.HandlerFunc wsPath string sseBroker *SSEBroker swaggerEnabled bool @@ -262,8 +263,13 @@ func (e *Engine) build() *gin.Engine { g.RegisterRoutes(rg) } - // Mount WebSocket handler if configured. - if e.wsHandler != nil { + // 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. + switch { + case e.wsGinHandler != nil: + r.GET(resolveWSPath(e.wsPath), e.wsGinHandler) + case e.wsHandler != nil: r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler)) } diff --git a/cmd/api/cmd.go b/cmd/api/cmd.go index b23d546..9ff267d 100644 --- a/cmd/api/cmd.go +++ b/cmd/api/cmd.go @@ -1,23 +1,32 @@ // SPDX-License-Identifier: EUPL-1.2 +// Package api registers the `core api` command group on the root Core +// instance. It exposes two subcommands: +// +// - api/spec — generate the OpenAPI specification from registered route +// groups plus the built-in tool bridge and write it to stdout or a file. +// - api/sdk — run openapi-generator-cli over a generated spec to produce +// client SDKs in the configured target languages. +// +// The commands use the Core framework's declarative Command API. Flags are +// declared via the Flags Options map and read from the incoming Options +// during the Action. package api -import "dappco.re/go/core/cli/pkg/cli" +import ( + "dappco.re/go/core" + "dappco.re/go/core/cli/pkg/cli" +) func init() { cli.RegisterCommands(AddAPICommands) } -// AddAPICommands registers the `api` command group. +// AddAPICommands registers the `api/spec` and `api/sdk` commands on the given +// Core instance. // -// Example: -// -// root := cli.NewGroup("root", "", "") -// api.AddAPICommands(root) -func AddAPICommands(root *cli.Command) { - apiCmd := cli.NewGroup("api", "API specification and SDK generation", "") - root.AddCommand(apiCmd) - - addSpecCommand(apiCmd) - addSDKCommand(apiCmd) +// core.RegisterCommands(api.AddAPICommands) +func AddAPICommands(c *core.Core) { + addSpecCommand(c) + addSDKCommand(c) } diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index f309ec8..a29a059 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -3,17 +3,14 @@ package api import ( - "fmt" "iter" "os" - "strings" + core "dappco.re/go/core" "dappco.re/go/core/cli/pkg/cli" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" - goapi "dappco.re/go/core/api" + coreio "dappco.re/go/core/io" ) const ( @@ -22,124 +19,95 @@ const ( defaultSDKVersion = "1.0.0" ) -func addSDKCommand(parent *cli.Command) { - var ( - lang string - output string - specFile string - packageName string - cfg specBuilderConfig - ) - - cfg.title = defaultSDKTitle - cfg.description = defaultSDKDescription - cfg.version = defaultSDKVersion - - cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error { - languages := splitUniqueCSV(lang) - if len(languages) == 0 { - return coreerr.E("sdk.Generate", "--lang is required and must include at least one non-empty language. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil) - } - - gen := &goapi.SDKGenerator{ - OutputDir: output, - PackageName: packageName, - } - - if !gen.Available() { - fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:") - fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)") - fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g") - return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil) - } - - // If no spec file was provided, generate one only after confirming the - // generator is available. - resolvedSpecFile := specFile - if resolvedSpecFile == "" { - builder, err := sdkSpecBuilder(cfg) - if err != nil { - return err - } - groups := sdkSpecGroupsIter() - - tmpFile, err := os.CreateTemp("", "openapi-*.json") - if err != nil { - return coreerr.E("sdk.Generate", "create temp spec file", err) - } - tmpPath := tmpFile.Name() - if err := tmpFile.Close(); err != nil { - _ = coreio.Local.Delete(tmpPath) - return coreerr.E("sdk.Generate", "close temp spec file", err) - } - defer coreio.Local.Delete(tmpPath) - - if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil { - return coreerr.E("sdk.Generate", "generate spec", err) - } - resolvedSpecFile = tmpPath - } - - gen.SpecPath = resolvedSpecFile - - // Generate for each language. - for _, l := range languages { - fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l) - if err := gen.Generate(cli.Context(), l); err != nil { - return coreerr.E("sdk.Generate", "generate "+l, err) - } - fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l) - } - - return nil +func addSDKCommand(c *core.Core) { + c.Command("api/sdk", core.Command{ + Description: "Generate client SDKs from OpenAPI spec", + Action: sdkAction, }) +} - cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)") - cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs") - cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to an existing OpenAPI spec (generates a temporary spec from registered route groups and the built-in tool bridge if not provided)") - cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK") - registerSpecBuilderFlags(cmd, &cfg) +func sdkAction(opts core.Options) core.Result { + lang := opts.String("lang") + output := opts.String("output") + if output == "" { + output = "./sdk" + } + specFile := opts.String("spec") + packageName := opts.String("package") + if packageName == "" { + packageName = "lethean" + } - parent.AddCommand(cmd) + languages := splitUniqueCSV(lang) + if len(languages) == 0 { + return core.Result{Value: cli.Err("--lang is required and must include at least one non-empty language"), OK: false} + } + + gen := &goapi.SDKGenerator{ + OutputDir: output, + PackageName: packageName, + } + + if !gen.Available() { + cli.Error("openapi-generator-cli not found. Install with:") + cli.Print(" brew install openapi-generator (macOS)") + cli.Print(" npm install @openapitools/openapi-generator-cli -g") + return core.Result{Value: cli.Err("openapi-generator-cli not installed"), OK: false} + } + + resolvedSpecFile := specFile + if resolvedSpecFile == "" { + cfg := sdkConfigFromOptions(opts) + builder, err := sdkSpecBuilder(cfg) + if err != nil { + return core.Result{Value: err, OK: false} + } + groups := sdkSpecGroupsIter() + + tmpFile, err := os.CreateTemp("", "openapi-*.json") + if err != nil { + return core.Result{Value: cli.Wrap(err, "create temp spec file"), OK: false} + } + tmpPath := tmpFile.Name() + if err := tmpFile.Close(); err != nil { + _ = coreio.Local.Delete(tmpPath) + return core.Result{Value: cli.Wrap(err, "close temp spec file"), OK: false} + } + defer coreio.Local.Delete(tmpPath) + + if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil { + return core.Result{Value: cli.Wrap(err, "generate spec"), OK: false} + } + resolvedSpecFile = tmpPath + } + + gen.SpecPath = resolvedSpecFile + + for _, l := range languages { + cli.Dim("Generating " + l + " SDK...") + if err := gen.Generate(cli.Context(), l); err != nil { + return core.Result{Value: cli.Wrap(err, "generate "+l), OK: false} + } + cli.Dim(" Done: " + output + "/" + l + "/") + } + + return core.Result{OK: true} } func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { - return newSpecBuilder(specBuilderConfig{ - title: cfg.title, - summary: cfg.summary, - description: cfg.description, - version: cfg.version, - swaggerPath: cfg.swaggerPath, - graphqlPath: cfg.graphqlPath, - graphqlPlayground: cfg.graphqlPlayground, - graphqlPlaygroundPath: cfg.graphqlPlaygroundPath, - ssePath: cfg.ssePath, - wsPath: cfg.wsPath, - pprofEnabled: cfg.pprofEnabled, - expvarEnabled: cfg.expvarEnabled, - cacheEnabled: cfg.cacheEnabled, - cacheTTL: cfg.cacheTTL, - cacheMaxEntries: cfg.cacheMaxEntries, - cacheMaxBytes: cfg.cacheMaxBytes, - i18nDefaultLocale: cfg.i18nDefaultLocale, - i18nSupportedLocales: cfg.i18nSupportedLocales, - authentikIssuer: cfg.authentikIssuer, - authentikClientID: cfg.authentikClientID, - authentikTrustedProxy: cfg.authentikTrustedProxy, - authentikPublicPaths: cfg.authentikPublicPaths, - termsURL: cfg.termsURL, - contactName: cfg.contactName, - contactURL: cfg.contactURL, - contactEmail: cfg.contactEmail, - licenseName: cfg.licenseName, - licenseURL: cfg.licenseURL, - externalDocsDescription: cfg.externalDocsDescription, - externalDocsURL: cfg.externalDocsURL, - servers: cfg.servers, - securitySchemes: cfg.securitySchemes, - }) + return newSpecBuilder(cfg) } func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] { return specGroupsIter(goapi.NewToolBridge("/tools")) } + +// sdkConfigFromOptions mirrors specConfigFromOptions but falls back to +// SDK-specific title/description/version defaults. +func sdkConfigFromOptions(opts core.Options) specBuilderConfig { + cfg := specConfigFromOptions(opts) + cfg.title = stringOr(opts.String("title"), defaultSDKTitle) + cfg.description = stringOr(opts.String("description"), defaultSDKDescription) + cfg.version = stringOr(opts.String("version"), defaultSDKVersion) + return cfg +} diff --git a/cmd/api/cmd_sdk_test.go b/cmd/api/cmd_sdk_test.go new file mode 100644 index 0000000..cba5371 --- /dev/null +++ b/cmd/api/cmd_sdk_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "testing" + + core "dappco.re/go/core" + + api "dappco.re/go/core/api" +) + +// TestCmdSdk_AddSDKCommand_Good verifies the sdk command registers under +// the expected api/sdk path with an executable Action. +func TestCmdSdk_AddSDKCommand_Good(t *testing.T) { + c := core.New() + addSDKCommand(c) + + r := c.Command("api/sdk") + if !r.OK { + t.Fatalf("expected api/sdk command to be registered") + } + cmd, ok := r.Value.(*core.Command) + if !ok { + t.Fatalf("expected *core.Command, got %T", r.Value) + } + if cmd.Action == nil { + t.Fatal("expected non-nil Action on api/sdk") + } + if cmd.Description == "" { + t.Fatal("expected Description on api/sdk") + } +} + +// TestCmdSdk_SdkAction_Bad_RequiresLanguage rejects invocations that omit +// the --lang flag entirely. +func TestCmdSdk_SdkAction_Bad_RequiresLanguage(t *testing.T) { + opts := core.NewOptions() + r := sdkAction(opts) + if r.OK { + t.Fatal("expected sdk action to fail without --lang") + } +} + +// TestCmdSdk_SdkAction_Bad_EmptyLanguageList rejects --lang values that +// resolve to no real language entries after splitting and trimming. +func TestCmdSdk_SdkAction_Bad_EmptyLanguageList(t *testing.T) { + opts := core.NewOptions( + core.Option{Key: "lang", Value: " , , "}, + ) + r := sdkAction(opts) + if r.OK { + t.Fatal("expected sdk action to fail when --lang only contains separators") + } +} + +// TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge verifies the SDK +// builder always exposes the bundled tools route group. +func TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge(t *testing.T) { + snapshot := api.RegisteredSpecGroups() + api.ResetSpecGroups() + t.Cleanup(func() { + api.ResetSpecGroups() + api.RegisterSpecGroups(snapshot...) + }) + + groups := collectRouteGroups(sdkSpecGroupsIter()) + if len(groups) == 0 { + t.Fatal("expected at least the bundled tools bridge") + } + + found := false + for _, g := range groups { + if g.BasePath() == "/tools" { + found = true + break + } + } + if !found { + t.Fatal("expected /tools route group in sdk spec iterator") + } +} + +// TestCmdSdk_SdkConfigFromOptions_Ugly_FallsBackToSDKDefaults exercises the +// SDK-specific default fallbacks for empty title/description/version. +func TestCmdSdk_SdkConfigFromOptions_Ugly_FallsBackToSDKDefaults(t *testing.T) { + opts := core.NewOptions() + cfg := sdkConfigFromOptions(opts) + if cfg.title != defaultSDKTitle { + t.Fatalf("expected default SDK title, got %q", cfg.title) + } + if cfg.description != defaultSDKDescription { + t.Fatalf("expected default SDK description, got %q", cfg.description) + } + if cfg.version != defaultSDKVersion { + t.Fatalf("expected default SDK version, got %q", cfg.version) + } +} diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index 0abb259..8566961 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -7,49 +7,46 @@ import ( "os" core "dappco.re/go/core" - "dappco.re/go/core/cli/pkg/cli" goapi "dappco.re/go/core/api" ) -func addSpecCommand(parent *cli.Command) { - var ( - output string - format string - cfg specBuilderConfig - ) - - cfg.title = "Lethean Core API" - cfg.description = "Lethean Core API" - cfg.version = "1.0.0" - - cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error { - // Build spec from all route groups registered for CLI generation. - builder, err := newSpecBuilder(cfg) - if err != nil { - return err - } - - bridge := goapi.NewToolBridge("/tools") - groups := specGroupsIter(bridge) - - if output != "" { - if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil { - return err - } - _, _ = os.Stderr.Write([]byte(core.Sprintf("Spec written to %s\n", output))) - return nil - } - - return goapi.ExportSpecIter(os.Stdout, format, builder, groups) +func addSpecCommand(c *core.Core) { + c.Command("api/spec", core.Command{ + Description: "Generate OpenAPI specification", + Action: specAction, }) +} - cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout") - cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml") - registerSpecBuilderFlags(cmd, &cfg) +func specAction(opts core.Options) core.Result { + cfg := specConfigFromOptions(opts) + output := opts.String("output") + format := opts.String("format") + if format == "" { + format = "json" + } - parent.AddCommand(cmd) + builder, err := newSpecBuilder(cfg) + if err != nil { + return core.Result{Value: err, OK: false} + } + + bridge := goapi.NewToolBridge("/tools") + groups := specGroupsIter(bridge) + + if output != "" { + if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil { + return core.Result{Value: cli.Wrap(err, "write spec"), OK: false} + } + cli.Dim("Spec written to " + output) + return core.Result{OK: true} + } + + if err := goapi.ExportSpecIter(os.Stdout, format, builder, groups); err != nil { + return core.Result{Value: cli.Wrap(err, "render spec"), OK: false} + } + return core.Result{OK: true} } func parseServers(raw string) []string { @@ -64,42 +61,55 @@ func parseSecuritySchemes(raw string) (map[string]any, error) { var schemes map[string]any if err := json.Unmarshal([]byte(raw), &schemes); err != nil { - return nil, cli.Err("invalid security schemes JSON: %w", err) + return nil, cli.Wrap(err, "invalid security schemes JSON") } return schemes, nil } -func registerSpecBuilderFlags(cmd *cli.Command, cfg *specBuilderConfig) { - cli.StringFlag(cmd, &cfg.title, "title", "t", cfg.title, "API title in spec") - cli.StringFlag(cmd, &cfg.summary, "summary", "", cfg.summary, "OpenAPI info summary in spec") - cli.StringFlag(cmd, &cfg.description, "description", "d", cfg.description, "API description in spec") - cli.StringFlag(cmd, &cfg.version, "version", "V", cfg.version, "API version in spec") - cli.StringFlag(cmd, &cfg.swaggerPath, "swagger-path", "", "", "Swagger UI path in generated spec") - cli.StringFlag(cmd, &cfg.graphqlPath, "graphql-path", "", "", "GraphQL endpoint path in generated spec") - cli.BoolFlag(cmd, &cfg.graphqlPlayground, "graphql-playground", "", false, "Include the GraphQL playground endpoint in generated spec") - cli.StringFlag(cmd, &cfg.graphqlPlaygroundPath, "graphql-playground-path", "", "", "GraphQL playground path in generated spec") - cli.StringFlag(cmd, &cfg.ssePath, "sse-path", "", "", "SSE endpoint path in generated spec") - cli.StringFlag(cmd, &cfg.wsPath, "ws-path", "", "", "WebSocket endpoint path in generated spec") - cli.BoolFlag(cmd, &cfg.pprofEnabled, "pprof", "", false, "Include pprof endpoints in generated spec") - cli.BoolFlag(cmd, &cfg.expvarEnabled, "expvar", "", false, "Include expvar endpoint in generated spec") - cli.BoolFlag(cmd, &cfg.cacheEnabled, "cache", "", false, "Include cache metadata in generated spec") - cli.StringFlag(cmd, &cfg.cacheTTL, "cache-ttl", "", "", "Cache TTL in generated spec") - cli.IntFlag(cmd, &cfg.cacheMaxEntries, "cache-max-entries", "", 0, "Cache max entries in generated spec") - cli.IntFlag(cmd, &cfg.cacheMaxBytes, "cache-max-bytes", "", 0, "Cache max bytes in generated spec") - cli.StringFlag(cmd, &cfg.i18nDefaultLocale, "i18n-default-locale", "", "", "Default locale in generated spec") - cli.StringFlag(cmd, &cfg.i18nSupportedLocales, "i18n-supported-locales", "", "", "Comma-separated supported locales in generated spec") - cli.StringFlag(cmd, &cfg.authentikIssuer, "authentik-issuer", "", "", "Authentik issuer URL in generated spec") - cli.StringFlag(cmd, &cfg.authentikClientID, "authentik-client-id", "", "", "Authentik client ID in generated spec") - cli.BoolFlag(cmd, &cfg.authentikTrustedProxy, "authentik-trusted-proxy", "", false, "Mark Authentik proxy headers as trusted in generated spec") - cli.StringFlag(cmd, &cfg.authentikPublicPaths, "authentik-public-paths", "", "", "Comma-separated public paths in generated spec") - cli.StringFlag(cmd, &cfg.termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec") - cli.StringFlag(cmd, &cfg.contactName, "contact-name", "", "", "OpenAPI contact name in spec") - cli.StringFlag(cmd, &cfg.contactURL, "contact-url", "", "", "OpenAPI contact URL in spec") - cli.StringFlag(cmd, &cfg.contactEmail, "contact-email", "", "", "OpenAPI contact email in spec") - cli.StringFlag(cmd, &cfg.licenseName, "license-name", "", "", "OpenAPI licence name in spec") - cli.StringFlag(cmd, &cfg.licenseURL, "license-url", "", "", "OpenAPI licence URL in spec") - cli.StringFlag(cmd, &cfg.externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in spec") - cli.StringFlag(cmd, &cfg.externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in spec") - cli.StringFlag(cmd, &cfg.servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)") - cli.StringFlag(cmd, &cfg.securitySchemes, "security-schemes", "", "", "JSON object of custom OpenAPI security schemes") +// specConfigFromOptions extracts a specBuilderConfig from the CLI options bag. +// Callers supply flags via `--key=value` on the command line; the CLI parser +// converts them to the option keys read here. +func specConfigFromOptions(opts core.Options) specBuilderConfig { + cfg := specBuilderConfig{ + title: stringOr(opts.String("title"), "Lethean Core API"), + summary: opts.String("summary"), + description: stringOr(opts.String("description"), "Lethean Core API"), + version: stringOr(opts.String("version"), "1.0.0"), + swaggerPath: opts.String("swagger-path"), + graphqlPath: opts.String("graphql-path"), + graphqlPlayground: opts.Bool("graphql-playground"), + graphqlPlaygroundPath: opts.String("graphql-playground-path"), + ssePath: opts.String("sse-path"), + wsPath: opts.String("ws-path"), + pprofEnabled: opts.Bool("pprof"), + expvarEnabled: opts.Bool("expvar"), + cacheEnabled: opts.Bool("cache"), + cacheTTL: opts.String("cache-ttl"), + cacheMaxEntries: opts.Int("cache-max-entries"), + cacheMaxBytes: opts.Int("cache-max-bytes"), + i18nDefaultLocale: opts.String("i18n-default-locale"), + i18nSupportedLocales: opts.String("i18n-supported-locales"), + authentikIssuer: opts.String("authentik-issuer"), + authentikClientID: opts.String("authentik-client-id"), + authentikTrustedProxy: opts.Bool("authentik-trusted-proxy"), + authentikPublicPaths: opts.String("authentik-public-paths"), + termsURL: opts.String("terms-of-service"), + contactName: opts.String("contact-name"), + contactURL: opts.String("contact-url"), + contactEmail: opts.String("contact-email"), + licenseName: opts.String("license-name"), + licenseURL: opts.String("license-url"), + externalDocsDescription: opts.String("external-docs-description"), + externalDocsURL: opts.String("external-docs-url"), + servers: opts.String("server"), + securitySchemes: opts.String("security-schemes"), + } + return cfg +} + +func stringOr(v, fallback string) string { + if core.Trim(v) == "" { + return fallback + } + return v } diff --git a/cmd/api/cmd_spec_test.go b/cmd/api/cmd_spec_test.go new file mode 100644 index 0000000..e4cfb33 --- /dev/null +++ b/cmd/api/cmd_spec_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "encoding/json" + "iter" + "os" + "testing" + + core "dappco.re/go/core" + "github.com/gin-gonic/gin" + + api "dappco.re/go/core/api" +) + +type specCmdStubGroup struct{} + +func (specCmdStubGroup) Name() string { return "registered" } +func (specCmdStubGroup) BasePath() string { return "/registered" } +func (specCmdStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (specCmdStubGroup) Describe() []api.RouteDescription { + return []api.RouteDescription{ + { + Method: "GET", + Path: "/ping", + Summary: "Ping registered group", + Tags: []string{"registered"}, + Response: map[string]any{ + "type": "string", + }, + }, + } +} + +func collectRouteGroups(groups iter.Seq[api.RouteGroup]) []api.RouteGroup { + out := make([]api.RouteGroup, 0) + for group := range groups { + out = append(out, group) + } + return out +} + +// TestCmdSpec_AddSpecCommand_Good verifies the spec command registers under +// the expected api/spec path with an executable Action. +func TestCmdSpec_AddSpecCommand_Good(t *testing.T) { + c := core.New() + addSpecCommand(c) + + r := c.Command("api/spec") + if !r.OK { + t.Fatalf("expected api/spec command to be registered") + } + cmd, ok := r.Value.(*core.Command) + if !ok { + t.Fatalf("expected *core.Command, got %T", r.Value) + } + if cmd.Action == nil { + t.Fatal("expected non-nil Action on api/spec") + } + if cmd.Description == "" { + t.Fatal("expected Description on api/spec") + } +} + +// TestCmdSpec_SpecAction_Good_WritesJSONToFile exercises the spec action with +// an output file flag and verifies the resulting OpenAPI document parses. +func TestCmdSpec_SpecAction_Good_WritesJSONToFile(t *testing.T) { + outputFile := t.TempDir() + "/spec.json" + opts := core.NewOptions( + core.Option{Key: "output", Value: outputFile}, + core.Option{Key: "format", Value: "json"}, + ) + + r := specAction(opts) + if !r.OK { + t.Fatalf("expected OK result, got %v", r.Value) + } + + data, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("expected spec file to be written: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("expected valid JSON spec, got error: %v", err) + } + if spec["openapi"] == nil { + t.Fatal("expected openapi field in generated spec") + } +} + +// TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved ensures that flag +// keys from the CLI parser populate the spec builder configuration. +func TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved(t *testing.T) { + opts := core.NewOptions( + core.Option{Key: "title", Value: "Custom API"}, + core.Option{Key: "summary", Value: "Brief summary"}, + core.Option{Key: "description", Value: "Long description"}, + core.Option{Key: "version", Value: "9.9.9"}, + core.Option{Key: "swagger-path", Value: "/docs"}, + core.Option{Key: "graphql-playground", Value: true}, + core.Option{Key: "cache", Value: true}, + core.Option{Key: "cache-max-entries", Value: 100}, + core.Option{Key: "i18n-supported-locales", Value: "en-GB,fr"}, + ) + + cfg := specConfigFromOptions(opts) + + if cfg.title != "Custom API" { + t.Fatalf("expected title=Custom API, got %q", cfg.title) + } + if cfg.summary != "Brief summary" { + t.Fatalf("expected summary preserved, got %q", cfg.summary) + } + if cfg.version != "9.9.9" { + t.Fatalf("expected version=9.9.9, got %q", cfg.version) + } + if cfg.swaggerPath != "/docs" { + t.Fatalf("expected swagger path, got %q", cfg.swaggerPath) + } + if !cfg.graphqlPlayground { + t.Fatal("expected graphql playground enabled") + } + if !cfg.cacheEnabled { + t.Fatal("expected cache enabled") + } + if cfg.cacheMaxEntries != 100 { + t.Fatalf("expected cacheMaxEntries=100, got %d", cfg.cacheMaxEntries) + } + if cfg.i18nSupportedLocales != "en-GB,fr" { + t.Fatalf("expected i18n supported locales, got %q", cfg.i18nSupportedLocales) + } +} + +// TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied ensures empty values +// do not blank out required defaults like title, description, version. +func TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied(t *testing.T) { + opts := core.NewOptions() + cfg := specConfigFromOptions(opts) + + if cfg.title != "Lethean Core API" { + t.Fatalf("expected default title, got %q", cfg.title) + } + if cfg.description != "Lethean Core API" { + t.Fatalf("expected default description, got %q", cfg.description) + } + if cfg.version != "1.0.0" { + t.Fatalf("expected default version, got %q", cfg.version) + } +} + +// TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback covers the awkward +// whitespace path where an option is set to whitespace but should still +// fall back to the supplied default. +func TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback(t *testing.T) { + if got := stringOr(" ", "fallback"); got != "fallback" { + t.Fatalf("expected whitespace to fall back to default, got %q", got) + } + if got := stringOr("value", "fallback"); got != "value" { + t.Fatalf("expected explicit value to win, got %q", got) + } + if got := stringOr("", ""); got != "" { + t.Fatalf("expected empty/empty to remain empty, got %q", got) + } +} + +// 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. +func TestSpecGroupsIter_Good_DeduplicatesExtraBridge(t *testing.T) { + snapshot := api.RegisteredSpecGroups() + api.ResetSpecGroups() + t.Cleanup(func() { + api.ResetSpecGroups() + api.RegisterSpecGroups(snapshot...) + }) + + group := specCmdStubGroup{} + api.RegisterSpecGroups(group) + + groups := collectRouteGroups(specGroupsIter(group)) + + if len(groups) != 1 { + t.Fatalf("expected duplicate extra group to be skipped, got %d groups", len(groups)) + } + if groups[0].Name() != group.Name() || groups[0].BasePath() != group.BasePath() { + t.Fatalf("expected original group to be preserved, got %s at %s", groups[0].Name(), groups[0].BasePath()) + } +} diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go deleted file mode 100644 index 2f81e64..0000000 --- a/cmd/api/cmd_test.go +++ /dev/null @@ -1,1422 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "bytes" - "encoding/json" - "iter" - "os" - "testing" - - "github.com/gin-gonic/gin" - - "dappco.re/go/core/cli/pkg/cli" - - api "dappco.re/go/core/api" -) - -type specCmdStubGroup struct{} - -func (specCmdStubGroup) Name() string { return "registered" } -func (specCmdStubGroup) BasePath() string { return "/registered" } -func (specCmdStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} -func (specCmdStubGroup) Describe() []api.RouteDescription { - return []api.RouteDescription{ - { - Method: "GET", - Path: "/ping", - Summary: "Ping registered group", - Tags: []string{"registered"}, - Response: map[string]any{ - "type": "string", - }, - }, - } -} - -func collectRouteGroups(groups iter.Seq[api.RouteGroup]) []api.RouteGroup { - out := make([]api.RouteGroup, 0) - for group := range groups { - out = append(out, group) - } - return out -} - -func TestAPISpecCmd_Good_CommandStructure(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - apiCmd, _, err := root.Find([]string{"api"}) - if err != nil { - t.Fatalf("api command not found: %v", err) - } - - specCmd, _, err := apiCmd.Find([]string{"spec"}) - if err != nil { - t.Fatalf("spec subcommand not found: %v", err) - } - if specCmd.Use != "spec" { - t.Fatalf("expected Use=spec, got %s", specCmd.Use) - } -} - -func TestAPISpecCmd_Good_JSON(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - apiCmd, _, err := root.Find([]string{"api"}) - if err != nil { - t.Fatalf("api command not found: %v", err) - } - - specCmd, _, err := apiCmd.Find([]string{"spec"}) - if err != nil { - t.Fatalf("spec subcommand not found: %v", err) - } - - // Verify flags exist - if specCmd.Flag("format") == nil { - t.Fatal("expected --format flag on spec command") - } - if specCmd.Flag("output") == nil { - t.Fatal("expected --output flag on spec command") - } - if specCmd.Flag("title") == nil { - t.Fatal("expected --title flag on spec command") - } - if specCmd.Flag("summary") == nil { - t.Fatal("expected --summary flag on spec command") - } - if specCmd.Flag("description") == nil { - t.Fatal("expected --description flag on spec command") - } - if specCmd.Flag("version") == nil { - t.Fatal("expected --version flag on spec command") - } - if specCmd.Flag("swagger-path") == nil { - t.Fatal("expected --swagger-path flag on spec command") - } - if specCmd.Flag("graphql-path") == nil { - t.Fatal("expected --graphql-path flag on spec command") - } - if specCmd.Flag("graphql-playground") == nil { - t.Fatal("expected --graphql-playground flag on spec command") - } - if specCmd.Flag("graphql-playground-path") == nil { - t.Fatal("expected --graphql-playground-path flag on spec command") - } - if specCmd.Flag("sse-path") == nil { - t.Fatal("expected --sse-path flag on spec command") - } - if specCmd.Flag("ws-path") == nil { - t.Fatal("expected --ws-path flag on spec command") - } - if specCmd.Flag("pprof") == nil { - t.Fatal("expected --pprof flag on spec command") - } - if specCmd.Flag("expvar") == nil { - t.Fatal("expected --expvar flag on spec command") - } - if specCmd.Flag("cache") == nil { - t.Fatal("expected --cache flag on spec command") - } - if specCmd.Flag("cache-ttl") == nil { - t.Fatal("expected --cache-ttl flag on spec command") - } - if specCmd.Flag("cache-max-entries") == nil { - t.Fatal("expected --cache-max-entries flag on spec command") - } - if specCmd.Flag("cache-max-bytes") == nil { - t.Fatal("expected --cache-max-bytes flag on spec command") - } - if specCmd.Flag("i18n-default-locale") == nil { - t.Fatal("expected --i18n-default-locale flag on spec command") - } - if specCmd.Flag("i18n-supported-locales") == nil { - t.Fatal("expected --i18n-supported-locales flag on spec command") - } - if specCmd.Flag("terms-of-service") == nil { - t.Fatal("expected --terms-of-service flag on spec command") - } - if specCmd.Flag("contact-name") == nil { - t.Fatal("expected --contact-name flag on spec command") - } - if specCmd.Flag("contact-url") == nil { - t.Fatal("expected --contact-url flag on spec command") - } - if specCmd.Flag("contact-email") == nil { - t.Fatal("expected --contact-email flag on spec command") - } - if specCmd.Flag("license-name") == nil { - t.Fatal("expected --license-name flag on spec command") - } - if specCmd.Flag("license-url") == nil { - t.Fatal("expected --license-url flag on spec command") - } - if specCmd.Flag("external-docs-description") == nil { - t.Fatal("expected --external-docs-description flag on spec command") - } - if specCmd.Flag("external-docs-url") == nil { - t.Fatal("expected --external-docs-url flag on spec command") - } - if specCmd.Flag("server") == nil { - t.Fatal("expected --server flag on spec command") - } - if specCmd.Flag("security-schemes") == nil { - t.Fatal("expected --security-schemes flag on spec command") - } -} - -func TestAPISpecCmd_Good_CustomDescription(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{"api", "spec", "--description", "Custom API description", "--swagger-path", "/docs", "--output", outputFile}) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var spec map[string]any - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - if got := spec["x-swagger-ui-path"]; got != "/docs" { - t.Fatalf("expected x-swagger-ui-path=/docs, got %v", got) - } - - info, ok := spec["info"].(map[string]any) - if !ok { - t.Fatal("expected info object in generated spec") - } - if info["description"] != "Custom API description" { - t.Fatalf("expected custom description, got %v", info["description"]) - } -} - -func TestAPISpecCmd_Good_SummaryPopulatesSpecInfo(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--summary", "Short API overview", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - info, ok := spec["info"].(map[string]any) - if !ok { - t.Fatal("expected info object in generated spec") - } - if info["summary"] != "Short API overview" { - t.Fatalf("expected summary to be preserved, got %v", info["summary"]) - } -} - -func TestNewSpecBuilder_Good_TrimsMetadata(t *testing.T) { - builder, err := newSpecBuilder(specBuilderConfig{ - title: " API Title ", - summary: " API Summary ", - description: " API Description ", - version: " 1.2.3 ", - termsURL: " https://example.com/terms ", - contactName: " API Support ", - contactURL: " https://example.com/support ", - contactEmail: " support@example.com ", - licenseName: " EUPL-1.2 ", - licenseURL: " https://eupl.eu/1.2/en/ ", - externalDocsDescription: " Developer guide ", - externalDocsURL: " https://example.com/docs ", - servers: " https://api.example.com , / ", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if builder.Title != "API Title" { - t.Fatalf("expected trimmed title, got %q", builder.Title) - } - if builder.Summary != "API Summary" { - t.Fatalf("expected trimmed summary, got %q", builder.Summary) - } - if builder.Description != "API Description" { - t.Fatalf("expected trimmed description, got %q", builder.Description) - } - if builder.Version != "1.2.3" { - t.Fatalf("expected trimmed version, got %q", builder.Version) - } - if builder.TermsOfService != "https://example.com/terms" { - t.Fatalf("expected trimmed terms URL, got %q", builder.TermsOfService) - } - if builder.ContactName != "API Support" { - t.Fatalf("expected trimmed contact name, got %q", builder.ContactName) - } - if builder.ContactURL != "https://example.com/support" { - t.Fatalf("expected trimmed contact URL, got %q", builder.ContactURL) - } - if builder.ContactEmail != "support@example.com" { - t.Fatalf("expected trimmed contact email, got %q", builder.ContactEmail) - } - if builder.LicenseName != "EUPL-1.2" { - t.Fatalf("expected trimmed licence name, got %q", builder.LicenseName) - } - if builder.LicenseURL != "https://eupl.eu/1.2/en/" { - t.Fatalf("expected trimmed licence URL, got %q", builder.LicenseURL) - } - if builder.ExternalDocsDescription != "Developer guide" { - t.Fatalf("expected trimmed external docs description, got %q", builder.ExternalDocsDescription) - } - if builder.ExternalDocsURL != "https://example.com/docs" { - t.Fatalf("expected trimmed external docs URL, got %q", builder.ExternalDocsURL) - } - if len(builder.Servers) != 2 || builder.Servers[0] != "https://api.example.com" || builder.Servers[1] != "/" { - t.Fatalf("expected trimmed servers, got %v", builder.Servers) - } -} - -func TestAPISpecCmd_Good_CacheAndI18nFlagsPopulateSpec(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--cache", - "--cache-ttl", "5m0s", - "--cache-max-entries", "42", - "--cache-max-bytes", "8192", - "--i18n-default-locale", "en-GB", - "--i18n-supported-locales", "en-GB,fr,en-GB", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - if got := spec["x-cache-enabled"]; got != true { - t.Fatalf("expected x-cache-enabled=true, got %v", got) - } - if got := spec["x-cache-ttl"]; got != "5m0s" { - t.Fatalf("expected x-cache-ttl=5m0s, got %v", got) - } - if got := spec["x-cache-max-entries"]; got != float64(42) { - t.Fatalf("expected x-cache-max-entries=42, got %v", got) - } - if got := spec["x-cache-max-bytes"]; got != float64(8192) { - t.Fatalf("expected x-cache-max-bytes=8192, got %v", got) - } - if got := spec["x-i18n-default-locale"]; got != "en-GB" { - t.Fatalf("expected x-i18n-default-locale=en-GB, got %v", got) - } - locales, ok := spec["x-i18n-supported-locales"].([]any) - if !ok { - t.Fatalf("expected x-i18n-supported-locales array, got %T", spec["x-i18n-supported-locales"]) - } - if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" { - t.Fatalf("expected supported locales [en-GB fr], got %v", locales) - } -} - -func TestNewSpecBuilder_Good_IgnoresNonPositiveCacheTTL(t *testing.T) { - builder, err := newSpecBuilder(specBuilderConfig{ - cacheTTL: "0s", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if builder.CacheEnabled { - t.Fatal("expected non-positive cache TTL to keep cache disabled") - } - if builder.CacheTTL != "0s" { - t.Fatalf("expected cache TTL metadata to be preserved, got %q", builder.CacheTTL) - } -} - -func TestNewSpecBuilder_Good_IgnoresCacheLimitsWithoutPositiveTTL(t *testing.T) { - builder, err := newSpecBuilder(specBuilderConfig{ - cacheMaxEntries: 42, - cacheMaxBytes: 8192, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if builder.CacheEnabled { - t.Fatal("expected cache limits without a positive TTL to keep cache disabled") - } - if builder.CacheMaxEntries != 42 { - t.Fatalf("expected cache max entries metadata to be preserved, got %d", builder.CacheMaxEntries) - } - if builder.CacheMaxBytes != 8192 { - t.Fatalf("expected cache max bytes metadata to be preserved, got %d", builder.CacheMaxBytes) - } -} - -func TestAPISpecCmd_Good_OmitsNonPositiveCacheTTLExtension(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--cache-ttl", "0s", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - if _, ok := spec["x-cache-ttl"]; ok { - t.Fatal("expected non-positive cache TTL to be omitted from generated spec") - } - if got := spec["x-cache-enabled"]; got != nil && got != false { - t.Fatalf("expected cache to remain disabled, got %v", got) - } -} - -func TestAPISpecCmd_Good_GraphQLPlaygroundFlagPopulatesSpecPaths(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--graphql-path", "/graphql", - "--graphql-playground", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatal("expected paths object in generated spec") - } - if _, ok := paths["/graphql/playground"]; !ok { - t.Fatal("expected GraphQL playground path in generated spec") - } -} - -func TestAPISpecCmd_Good_GraphQLPlaygroundPathFlagOverridesGeneratedPath(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--graphql-path", "/graphql", - "--graphql-playground", - "--graphql-playground-path", "/graphql-ui", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatal("expected paths object in generated spec") - } - if _, ok := paths["/graphql-ui"]; !ok { - t.Fatal("expected custom GraphQL playground path in generated spec") - } - if _, ok := paths["/graphql/playground"]; ok { - t.Fatal("expected default GraphQL playground path to be overridden") - } - - if got := spec["x-graphql-playground-path"]; got != "/graphql-ui" { - t.Fatalf("expected x-graphql-playground-path=/graphql-ui, got %v", got) - } -} - -func TestAPISpecCmd_Good_EnabledExtensionsFollowProvidedPaths(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--swagger-path", "/docs", - "--graphql-path", "/graphql", - "--ws-path", "/socket", - "--sse-path", "/events", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - if got := spec["x-swagger-enabled"]; got != true { - t.Fatalf("expected x-swagger-enabled=true, got %v", got) - } - if got := spec["x-graphql-enabled"]; got != true { - t.Fatalf("expected x-graphql-enabled=true, got %v", got) - } - if got := spec["x-ws-enabled"]; got != true { - t.Fatalf("expected x-ws-enabled=true, got %v", got) - } - if got := spec["x-sse-enabled"]; got != true { - t.Fatalf("expected x-sse-enabled=true, got %v", got) - } -} - -func TestAPISpecCmd_Good_AuthentikPublicPathsAreNormalised(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--authentik-public-paths", " /public/ ,docs,/public", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["x-authentik-public-paths"].([]any) - if !ok { - t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"]) - } - if len(paths) != 4 || paths[0] != "/health" || paths[1] != "/swagger" || paths[2] != "/public" || paths[3] != "/docs" { - t.Fatalf("expected normalised public paths [/health /swagger /public /docs], got %v", paths) - } -} - -func TestAPISpecCmd_Good_ContactFlagsPopulateSpecInfo(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--contact-name", "API Support", - "--contact-url", "https://example.com/support", - "--contact-email", "support@example.com", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - info, ok := spec["info"].(map[string]any) - if !ok { - t.Fatal("expected info object in generated spec") - } - - contact, ok := info["contact"].(map[string]any) - if !ok { - t.Fatal("expected contact metadata in generated spec") - } - if contact["name"] != "API Support" { - t.Fatalf("expected contact name API Support, got %v", contact["name"]) - } - if contact["url"] != "https://example.com/support" { - t.Fatalf("expected contact url to be preserved, got %v", contact["url"]) - } - if contact["email"] != "support@example.com" { - t.Fatalf("expected contact email to be preserved, got %v", contact["email"]) - } -} - -func TestAPISpecCmd_Good_SecuritySchemesFlagPopulatesSpecComponents(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--security-schemes", `{"apiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"}}`, - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - securitySchemes, ok := spec["components"].(map[string]any)["securitySchemes"].(map[string]any) - if !ok { - t.Fatal("expected securitySchemes object in generated spec") - } - apiKeyAuth, ok := securitySchemes["apiKeyAuth"].(map[string]any) - if !ok { - t.Fatal("expected apiKeyAuth security scheme in generated spec") - } - if apiKeyAuth["type"] != "apiKey" { - t.Fatalf("expected apiKeyAuth.type=apiKey, got %v", apiKeyAuth["type"]) - } - if apiKeyAuth["in"] != "header" { - t.Fatalf("expected apiKeyAuth.in=header, got %v", apiKeyAuth["in"]) - } - if apiKeyAuth["name"] != "X-API-Key" { - t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"]) - } -} - -func TestSpecGroupsIter_Good_DeduplicatesExtraBridge(t *testing.T) { - snapshot := api.RegisteredSpecGroups() - api.ResetSpecGroups() - t.Cleanup(func() { - api.ResetSpecGroups() - api.RegisterSpecGroups(snapshot...) - }) - - group := specCmdStubGroup{} - api.RegisterSpecGroups(group) - - var groups []api.RouteGroup - for g := range specGroupsIter(group) { - groups = append(groups, g) - } - - if len(groups) != 1 { - t.Fatalf("expected duplicate extra group to be skipped, got %d groups", len(groups)) - } - if groups[0].Name() != group.Name() || groups[0].BasePath() != group.BasePath() { - t.Fatalf("expected original group to be preserved, got %s at %s", groups[0].Name(), groups[0].BasePath()) - } -} - -func TestAPISpecCmd_Good_TermsOfServiceFlagPopulatesSpecInfo(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--terms-of-service", "https://example.com/terms", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - info, ok := spec["info"].(map[string]any) - if !ok { - t.Fatal("expected info object in generated spec") - } - if info["termsOfService"] != "https://example.com/terms" { - t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) - } -} - -func TestAPISpecCmd_Good_ExternalDocsFlagsPopulateSpec(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--external-docs-description", "Developer guide", - "--external-docs-url", "https://example.com/docs", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - externalDocs, ok := spec["externalDocs"].(map[string]any) - if !ok { - t.Fatal("expected externalDocs metadata in generated spec") - } - if externalDocs["description"] != "Developer guide" { - t.Fatalf("expected externalDocs description Developer guide, got %v", externalDocs["description"]) - } - if externalDocs["url"] != "https://example.com/docs" { - t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"]) - } -} - -func TestAPISpecCmd_Good_ServerFlagAddsServers(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{"api", "spec", "--server", "https://api.example.com, /, https://api.example.com, ", "--output", outputFile}) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - servers, ok := spec["servers"].([]any) - if !ok { - t.Fatalf("expected servers array in generated spec, got %T", spec["servers"]) - } - if len(servers) != 2 { - t.Fatalf("expected 2 servers, got %d", len(servers)) - } - if servers[0].(map[string]any)["url"] != "https://api.example.com" { - t.Fatalf("expected first server to be https://api.example.com, got %v", servers[0]) - } - if servers[1].(map[string]any)["url"] != "/" { - t.Fatalf("expected second server to be /, got %v", servers[1]) - } -} - -func TestAPISpecCmd_Good_RegisteredSpecGroups(t *testing.T) { - snapshot := api.RegisteredSpecGroups() - api.ResetSpecGroups() - t.Cleanup(func() { - api.ResetSpecGroups() - api.RegisterSpecGroups(snapshot...) - }) - - api.RegisterSpecGroups(specCmdStubGroup{}) - - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{"api", "spec", "--output", outputFile}) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatalf("expected paths object in generated spec, got %T", spec["paths"]) - } - - if _, ok := paths["/registered/ping"]; !ok { - t.Fatal("expected registered route group path in generated spec") - } -} - -func TestAPISpecCmd_Good_LicenseFlagsPopulateSpecInfo(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--license-name", "EUPL-1.2", - "--license-url", "https://eupl.eu/1.2/en/", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - info, ok := spec["info"].(map[string]any) - if !ok { - t.Fatal("expected info object in generated spec") - } - - license, ok := info["license"].(map[string]any) - if !ok { - t.Fatal("expected license metadata in generated spec") - } - if license["name"] != "EUPL-1.2" { - t.Fatalf("expected licence name EUPL-1.2, got %v", license["name"]) - } - if license["url"] != "https://eupl.eu/1.2/en/" { - t.Fatalf("expected licence url to be preserved, got %v", license["url"]) - } -} - -func TestAPISpecCmd_Good_GraphQLPathPopulatesSpec(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--graphql-path", "/gql", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatalf("expected paths object in generated spec, got %T", spec["paths"]) - } - - if _, ok := paths["/gql"]; !ok { - t.Fatal("expected GraphQL path to be included in generated spec") - } -} - -func TestAPISpecCmd_Good_SSEPathPopulatesSpec(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--sse-path", "/events", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatalf("expected paths object in generated spec, got %T", spec["paths"]) - } - - if _, ok := paths["/events"]; !ok { - t.Fatal("expected SSE path to be included in generated spec") - } -} - -func TestAPISpecCmd_Good_RuntimePathsPopulatedSpec(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--ws-path", "/ws", - "--pprof", - "--expvar", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatalf("expected paths object in generated spec, got %T", spec["paths"]) - } - - if _, ok := paths["/ws"]; !ok { - t.Fatal("expected WebSocket path to be included in generated spec") - } - if _, ok := paths["/debug/pprof"]; !ok { - t.Fatal("expected pprof path to be included in generated spec") - } - if _, ok := paths["/debug/vars"]; !ok { - t.Fatal("expected expvar path to be included in generated spec") - } -} - -func TestAPISpecCmd_Good_AuthentikFlagsPopulateSpecMetadata(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - outputFile := t.TempDir() + "/spec.json" - root.SetArgs([]string{ - "api", "spec", - "--authentik-issuer", "https://auth.example.com", - "--authentik-client-id", "core-client", - "--authentik-trusted-proxy", - "--authentik-public-paths", "/public, /docs, /public", - "--output", outputFile, - }) - root.SetErr(new(bytes.Buffer)) - - if err := root.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" { - t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got) - } - if got := spec["x-authentik-client-id"]; got != "core-client" { - t.Fatalf("expected x-authentik-client-id=core-client, got %v", got) - } - if got := spec["x-authentik-trusted-proxy"]; got != true { - t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got) - } - publicPaths, ok := spec["x-authentik-public-paths"].([]any) - if !ok { - t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"]) - } - if len(publicPaths) != 4 || publicPaths[0] != "/health" || publicPaths[1] != "/swagger" || publicPaths[2] != "/public" || publicPaths[3] != "/docs" { - t.Fatalf("expected public paths [/health /swagger /public /docs], got %v", publicPaths) - } -} - -func TestAPISDKCmd_Bad_EmptyLanguages(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - root.SetArgs([]string{"api", "sdk", "--lang", " , , "}) - buf := new(bytes.Buffer) - root.SetOut(buf) - root.SetErr(buf) - - err := root.Execute() - if err == nil { - t.Fatal("expected error when --lang only contains empty values") - } -} - -func TestAPISDKCmd_Bad_NoLang(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - root.SetArgs([]string{"api", "sdk"}) - buf := new(bytes.Buffer) - root.SetOut(buf) - root.SetErr(buf) - - err := root.Execute() - if err == nil { - t.Fatal("expected error when --lang not provided") - } -} - -func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) { - root := &cli.Command{Use: "root"} - AddAPICommands(root) - - apiCmd, _, err := root.Find([]string{"api"}) - if err != nil { - t.Fatalf("api command not found: %v", err) - } - - sdkCmd, _, err := apiCmd.Find([]string{"sdk"}) - if err != nil { - t.Fatalf("sdk subcommand not found: %v", err) - } - - // Verify flags exist - if sdkCmd.Flag("lang") == nil { - t.Fatal("expected --lang flag on sdk command") - } - if sdkCmd.Flag("output") == nil { - t.Fatal("expected --output flag on sdk command") - } - if sdkCmd.Flag("spec") == nil { - t.Fatal("expected --spec flag on sdk command") - } - if sdkCmd.Flag("package") == nil { - t.Fatal("expected --package flag on sdk command") - } - if sdkCmd.Flag("title") == nil { - t.Fatal("expected --title flag on sdk command") - } - if sdkCmd.Flag("description") == nil { - t.Fatal("expected --description flag on sdk command") - } - if sdkCmd.Flag("version") == nil { - t.Fatal("expected --version flag on sdk command") - } - if sdkCmd.Flag("swagger-path") == nil { - t.Fatal("expected --swagger-path flag on sdk command") - } - if sdkCmd.Flag("graphql-path") == nil { - t.Fatal("expected --graphql-path flag on sdk command") - } - if sdkCmd.Flag("sse-path") == nil { - t.Fatal("expected --sse-path flag on sdk command") - } - if sdkCmd.Flag("graphql-playground-path") == nil { - t.Fatal("expected --graphql-playground-path flag on sdk command") - } - if sdkCmd.Flag("ws-path") == nil { - t.Fatal("expected --ws-path flag on sdk command") - } - if sdkCmd.Flag("pprof") == nil { - t.Fatal("expected --pprof flag on sdk command") - } - if sdkCmd.Flag("expvar") == nil { - t.Fatal("expected --expvar flag on sdk command") - } - if sdkCmd.Flag("cache") == nil { - t.Fatal("expected --cache flag on sdk command") - } - if sdkCmd.Flag("cache-ttl") == nil { - t.Fatal("expected --cache-ttl flag on sdk command") - } - if sdkCmd.Flag("cache-max-entries") == nil { - t.Fatal("expected --cache-max-entries flag on sdk command") - } - if sdkCmd.Flag("cache-max-bytes") == nil { - t.Fatal("expected --cache-max-bytes flag on sdk command") - } - if sdkCmd.Flag("i18n-default-locale") == nil { - t.Fatal("expected --i18n-default-locale flag on sdk command") - } - if sdkCmd.Flag("i18n-supported-locales") == nil { - t.Fatal("expected --i18n-supported-locales flag on sdk command") - } - if sdkCmd.Flag("authentik-issuer") == nil { - t.Fatal("expected --authentik-issuer flag on sdk command") - } - if sdkCmd.Flag("authentik-client-id") == nil { - t.Fatal("expected --authentik-client-id flag on sdk command") - } - if sdkCmd.Flag("authentik-trusted-proxy") == nil { - t.Fatal("expected --authentik-trusted-proxy flag on sdk command") - } - if sdkCmd.Flag("authentik-public-paths") == nil { - t.Fatal("expected --authentik-public-paths flag on sdk command") - } - if sdkCmd.Flag("terms-of-service") == nil { - t.Fatal("expected --terms-of-service flag on sdk command") - } - if sdkCmd.Flag("contact-name") == nil { - t.Fatal("expected --contact-name flag on sdk command") - } - if sdkCmd.Flag("contact-url") == nil { - t.Fatal("expected --contact-url flag on sdk command") - } - if sdkCmd.Flag("contact-email") == nil { - t.Fatal("expected --contact-email flag on sdk command") - } - if sdkCmd.Flag("license-name") == nil { - t.Fatal("expected --license-name flag on sdk command") - } - if sdkCmd.Flag("license-url") == nil { - t.Fatal("expected --license-url flag on sdk command") - } - if sdkCmd.Flag("server") == nil { - t.Fatal("expected --server flag on sdk command") - } - if sdkCmd.Flag("security-schemes") == nil { - t.Fatal("expected --security-schemes flag on sdk command") - } -} - -func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) { - snapshot := api.RegisteredSpecGroups() - api.ResetSpecGroups() - t.Cleanup(func() { - api.ResetSpecGroups() - api.RegisterSpecGroups(snapshot...) - }) - - api.RegisterSpecGroups(specCmdStubGroup{}) - - builder, err := sdkSpecBuilder(specBuilderConfig{ - title: "Custom SDK API", - summary: "Custom SDK overview", - description: "Custom SDK description", - version: "9.9.9", - swaggerPath: "/docs", - graphqlPath: "/gql", - graphqlPlayground: true, - graphqlPlaygroundPath: "/gql/ide", - ssePath: "/events", - wsPath: "/ws", - pprofEnabled: true, - expvarEnabled: true, - cacheEnabled: true, - cacheTTL: "5m0s", - cacheMaxEntries: 42, - cacheMaxBytes: 8192, - i18nDefaultLocale: "en-GB", - i18nSupportedLocales: "en-GB,fr,en-GB", - authentikIssuer: "https://auth.example.com", - authentikClientID: "core-client", - authentikTrustedProxy: true, - authentikPublicPaths: "/public, /docs, /public", - termsURL: "https://example.com/terms", - contactName: "SDK Support", - contactURL: "https://example.com/support", - contactEmail: "support@example.com", - licenseName: "EUPL-1.2", - licenseURL: "https://eupl.eu/1.2/en/", - servers: "https://api.example.com, /, https://api.example.com", - securitySchemes: `{"apiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"}}`, - }) - if err != nil { - t.Fatalf("unexpected error building sdk spec: %v", err) - } - if builder.GraphQLPlaygroundPath != "/gql/ide" { - t.Fatalf("expected custom GraphQL playground path to be preserved in builder, got %q", builder.GraphQLPlaygroundPath) - } - groups := collectRouteGroups(sdkSpecGroupsIter()) - - outputFile := t.TempDir() + "/spec.json" - if err := api.ExportSpecToFile(outputFile, "json", builder, groups); err != nil { - t.Fatalf("unexpected error writing temp spec: %v", err) - } - - data, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("expected spec file to be written: %v", err) - } - - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("expected valid JSON spec, got error: %v", err) - } - - info, ok := spec["info"].(map[string]any) - if !ok { - t.Fatal("expected info object in generated spec") - } - if info["title"] != "Custom SDK API" { - t.Fatalf("expected custom title, got %v", info["title"]) - } - if info["description"] != "Custom SDK description" { - t.Fatalf("expected custom description, got %v", info["description"]) - } - if info["summary"] != "Custom SDK overview" { - t.Fatalf("expected custom summary, got %v", info["summary"]) - } - if info["version"] != "9.9.9" { - t.Fatalf("expected custom version, got %v", info["version"]) - } - - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatalf("expected paths object in generated spec, got %T", spec["paths"]) - } - if _, ok := paths["/gql"]; !ok { - t.Fatal("expected GraphQL path to be included in generated spec") - } - if got := builder.SwaggerPath; got != "/docs" { - t.Fatalf("expected swagger path to be preserved in sdk spec builder, got %v", got) - } - if _, ok := paths["/gql/ide"]; !ok { - t.Fatalf("expected custom GraphQL playground path to be included in generated spec, got keys %v", paths) - } - if _, ok := paths["/gql/playground"]; ok { - t.Fatal("expected custom GraphQL playground path to replace the default playground path") - } - if _, ok := paths["/events"]; !ok { - t.Fatal("expected SSE path to be included in generated spec") - } - if _, ok := paths["/ws"]; !ok { - t.Fatal("expected WebSocket path to be included in generated spec") - } - if _, ok := paths["/debug/pprof"]; !ok { - t.Fatal("expected pprof path to be included in generated spec") - } - if _, ok := paths["/debug/vars"]; !ok { - t.Fatal("expected expvar path to be included in generated spec") - } - - if got := spec["x-cache-enabled"]; got != true { - t.Fatalf("expected x-cache-enabled=true, got %v", got) - } - if got := spec["x-cache-ttl"]; got != "5m0s" { - t.Fatalf("expected x-cache-ttl=5m0s, got %v", got) - } - if got := spec["x-cache-max-entries"]; got != float64(42) { - t.Fatalf("expected x-cache-max-entries=42, got %v", got) - } - if got := spec["x-cache-max-bytes"]; got != float64(8192) { - t.Fatalf("expected x-cache-max-bytes=8192, got %v", got) - } - if got := spec["x-i18n-default-locale"]; got != "en-GB" { - t.Fatalf("expected x-i18n-default-locale=en-GB, got %v", got) - } - locales, ok := spec["x-i18n-supported-locales"].([]any) - if !ok { - t.Fatalf("expected x-i18n-supported-locales array, got %T", spec["x-i18n-supported-locales"]) - } - if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" { - t.Fatalf("expected supported locales [en-GB fr], got %v", locales) - } - if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" { - t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got) - } - if got := spec["x-authentik-client-id"]; got != "core-client" { - t.Fatalf("expected x-authentik-client-id=core-client, got %v", got) - } - if got := spec["x-authentik-trusted-proxy"]; got != true { - t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got) - } - publicPaths, ok := spec["x-authentik-public-paths"].([]any) - if !ok { - t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"]) - } - if len(publicPaths) != 4 || publicPaths[0] != "/health" || publicPaths[1] != "/swagger" || publicPaths[2] != "/docs" || publicPaths[3] != "/public" { - t.Fatalf("expected public paths [/health /swagger /docs /public], got %v", publicPaths) - } - - if info["termsOfService"] != "https://example.com/terms" { - t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) - } - - contact, ok := info["contact"].(map[string]any) - if !ok { - t.Fatal("expected contact metadata in generated spec") - } - if contact["name"] != "SDK Support" { - t.Fatalf("expected contact name SDK Support, got %v", contact["name"]) - } - if contact["url"] != "https://example.com/support" { - t.Fatalf("expected contact url to be preserved, got %v", contact["url"]) - } - if contact["email"] != "support@example.com" { - t.Fatalf("expected contact email to be preserved, got %v", contact["email"]) - } - - license, ok := info["license"].(map[string]any) - if !ok { - t.Fatal("expected licence metadata in generated spec") - } - if license["name"] != "EUPL-1.2" { - t.Fatalf("expected licence name EUPL-1.2, got %v", license["name"]) - } - if license["url"] != "https://eupl.eu/1.2/en/" { - t.Fatalf("expected licence url to be preserved, got %v", license["url"]) - } - - servers, ok := spec["servers"].([]any) - if !ok { - t.Fatalf("expected servers array in generated spec, got %T", spec["servers"]) - } - if len(servers) != 2 { - t.Fatalf("expected 2 servers, got %d", len(servers)) - } - if servers[0].(map[string]any)["url"] != "https://api.example.com" { - t.Fatalf("expected first server to be https://api.example.com, got %v", servers[0]) - } - if servers[1].(map[string]any)["url"] != "/" { - t.Fatalf("expected second server to be /, got %v", servers[1]) - } - - securitySchemes, ok := spec["components"].(map[string]any)["securitySchemes"].(map[string]any) - if !ok { - t.Fatal("expected securitySchemes in generated spec") - } - if _, ok := securitySchemes["apiKeyAuth"].(map[string]any); !ok { - t.Fatalf("expected apiKeyAuth security scheme in generated spec, got %v", securitySchemes) - } -} - -func TestAPISDKCmd_Good_SpecGroupsDeduplicateToolBridge(t *testing.T) { - snapshot := api.RegisteredSpecGroups() - api.ResetSpecGroups() - t.Cleanup(func() { - api.ResetSpecGroups() - api.RegisterSpecGroups(snapshot...) - }) - - api.RegisterSpecGroups(api.NewToolBridge("/tools")) - - groups := collectRouteGroups(sdkSpecGroupsIter()) - if len(groups) != 1 { - t.Fatalf("expected the built-in tools bridge to be deduplicated, got %d groups", len(groups)) - } - if groups[0].BasePath() != "/tools" { - t.Fatalf("expected the remaining group to be /tools, got %s", groups[0].BasePath()) - } -} diff --git a/group.go b/group.go index 7ee8798..9a7acdc 100644 --- a/group.go +++ b/group.go @@ -89,6 +89,9 @@ type RouteDescription struct { SunsetDate string // Replacement points to the successor endpoint URL, when known. Replacement string + // NoticeURL points to a detailed deprecation notice or migration guide, + // surfaced as the API-Deprecation-Notice-URL response header per spec §8. + NoticeURL string // StatusCode is the documented 2xx success status code. // Zero defaults to 200. StatusCode int diff --git a/openapi.go b/openapi.go index 7db8325..9933117 100644 --- a/openapi.go +++ b/openapi.go @@ -354,8 +354,13 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { for _, rd := range g.descs { fullPath := joinOpenAPIPath(g.basePath, rd.Path) method := core.Lower(rd.Method) - deprecated := rd.Deprecated || core.Trim(rd.SunsetDate) != "" || core.Trim(rd.Replacement) != "" + deprecated := rd.Deprecated || core.Trim(rd.SunsetDate) != "" || core.Trim(rd.Replacement) != "" || core.Trim(rd.NoticeURL) != "" deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement) + if deprecated && core.Trim(rd.NoticeURL) != "" && deprecationHeaders != nil { + deprecationHeaders["API-Deprecation-Notice-URL"] = map[string]any{ + "$ref": "#/components/headers/apiDeprecationNoticeURL", + } + } isPublic := isPublicPathForList(fullPath, publicPaths) security := rd.Security if isPublic { @@ -684,7 +689,9 @@ func healthResponses() map[string]any { } // deprecationResponseHeaders documents the standard deprecation headers for -// deprecated or sunsetted operations. +// deprecated or sunsetted operations. The header set mirrors what +// ApiSunset/ApiSunsetWith emit at runtime, including the spec §8 custom +// headers (API-Suggested-Replacement, API-Deprecation-Notice-URL). func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string) map[string]any { sunsetDate = core.Trim(sunsetDate) replacement = core.Trim(replacement) @@ -712,13 +719,18 @@ func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string) headers["Link"] = map[string]any{ "$ref": "#/components/headers/link", } + headers["API-Suggested-Replacement"] = map[string]any{ + "$ref": "#/components/headers/apiSuggestedReplacement", + } } return headers } // deprecationHeaderComponents returns reusable OpenAPI header components for -// the standard deprecation and sunset middleware headers. +// the standard deprecation and sunset middleware headers. Includes both the +// IETF-standard headers (Deprecation, Sunset, Link) and the custom spec §8 +// headers used to communicate replacement endpoints and migration guides. func deprecationHeaderComponents() map[string]any { return map[string]any{ "deprecation": map[string]any{ @@ -747,6 +759,19 @@ func deprecationHeaderComponents() map[string]any { "type": "string", }, }, + "apiSuggestedReplacement": map[string]any{ + "description": "Suggested replacement endpoint for clients to migrate to.", + "schema": map[string]any{ + "type": "string", + }, + }, + "apiDeprecationNoticeURL": map[string]any{ + "description": "URL pointing to a detailed deprecation notice.", + "schema": map[string]any{ + "type": "string", + "format": "uri", + }, + }, } } diff --git a/options.go b/options.go index 33138b5..8299281 100644 --- a/options.go +++ b/options.go @@ -153,6 +153,27 @@ func WithWSHandler(h http.Handler) Option { } } +// WithWebSocket registers a Gin-native WebSocket handler at GET /ws. +// +// This is the gin-handler form of WithWSHandler. The handler receives the +// request via *gin.Context and is responsible for performing the upgrade +// (typically with gorilla/websocket) and managing the message loop. +// Use WithWSPath to customise the route before mounting the handler. +// +// Example: +// +// api.New(api.WithWebSocket(func(c *gin.Context) { +// // upgrade and handle messages +// })) +func WithWebSocket(h gin.HandlerFunc) Option { + return func(e *Engine) { + if h == nil { + return + } + e.wsGinHandler = h + } +} + // WithWSPath sets a custom URL path for the WebSocket endpoint. // The default path is "/ws". // diff --git a/sunset.go b/sunset.go index d9163b2..ca240d2 100644 --- a/sunset.go +++ b/sunset.go @@ -11,17 +11,58 @@ import ( "github.com/gin-gonic/gin" ) +// SunsetOption customises the behaviour of ApiSunsetWith. Use the supplied +// constructors (e.g. WithSunsetNoticeURL) to compose the desired metadata +// without breaking the simpler ApiSunset signature. +// +// mw := api.ApiSunsetWith("2025-06-01", "/api/v2/users", +// api.WithSunsetNoticeURL("https://docs.example.com/deprecation/billing"), +// ) +type SunsetOption func(*sunsetConfig) + +// sunsetConfig carries optional metadata for ApiSunsetWith. +type sunsetConfig struct { + noticeURL string +} + +// WithSunsetNoticeURL adds the API-Deprecation-Notice-URL header documented +// in spec §8 to every response. The URL should point to a human-readable +// migration guide for the deprecated endpoint. +// +// api.ApiSunsetWith("2026-04-30", "POST /api/v2/billing/invoices", +// api.WithSunsetNoticeURL("https://docs.api.dappco.re/deprecation/billing"), +// ) +func WithSunsetNoticeURL(url string) SunsetOption { + return func(cfg *sunsetConfig) { + cfg.noticeURL = url + } +} + // ApiSunset returns middleware that marks a route or group as deprecated. // // The middleware appends standard deprecation headers to every response: -// Deprecation, optional Sunset, optional Link, and X-API-Warn. Existing header -// values are preserved so downstream middleware and handlers can keep their own -// link relations or warning metadata. +// Deprecation, optional Sunset, optional Link, optional API-Suggested-Replacement, +// and X-API-Warn. Existing header values are preserved so downstream middleware +// and handlers can keep their own link relations or warning metadata. // // Example: // // rg.Use(api.ApiSunset("2025-06-01", "/api/v2/users")) func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc { + return ApiSunsetWith(sunsetDate, replacement) +} + +// ApiSunsetWith is the extensible form of ApiSunset. It accepts SunsetOption +// values to attach optional metadata such as the deprecation notice URL. +// +// Example: +// +// rg.Use(api.ApiSunsetWith( +// "2026-04-30", +// "POST /api/v2/billing/invoices", +// api.WithSunsetNoticeURL("https://docs.api.dappco.re/deprecation/billing"), +// )) +func ApiSunsetWith(sunsetDate, replacement string, opts ...SunsetOption) gin.HandlerFunc { sunsetDate = core.Trim(sunsetDate) replacement = core.Trim(replacement) formatted := formatSunsetDate(sunsetDate) @@ -30,6 +71,14 @@ func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc { warning = "This endpoint is deprecated and will be removed on " + sunsetDate + "." } + cfg := &sunsetConfig{} + for _, opt := range opts { + if opt != nil { + opt(cfg) + } + } + noticeURL := core.Trim(cfg.noticeURL) + return func(c *gin.Context) { c.Next() @@ -39,6 +88,10 @@ func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc { } if replacement != "" { c.Writer.Header().Add("Link", "<"+replacement+">; rel=\"successor-version\"") + c.Writer.Header().Add("API-Suggested-Replacement", replacement) + } + if noticeURL != "" { + c.Writer.Header().Add("API-Deprecation-Notice-URL", noticeURL) } c.Writer.Header().Add("X-API-Warn", warning) } diff --git a/sunset_test.go b/sunset_test.go index 1348be7..878e27b 100644 --- a/sunset_test.go +++ b/sunset_test.go @@ -72,11 +72,73 @@ func TestWithSunset_Good_AddsDeprecationHeaders(t *testing.T) { if got := w.Header().Get("Link"); got != "; rel=\"successor-version\"" { t.Fatalf("expected successor Link header, got %q", got) } + if got := w.Header().Get("API-Suggested-Replacement"); got != "/api/v2/status" { + t.Fatalf("expected API-Suggested-Replacement to mirror replacement URL, got %q", got) + } if got := w.Header().Get("X-API-Warn"); got != "This endpoint is deprecated and will be removed on 2025-06-01." { t.Fatalf("expected deprecation warning, got %q", got) } } +// TestApiSunsetWith_Good_AddsNoticeURLHeader exercises ApiSunsetWith with the +// WithSunsetNoticeURL option to verify the spec §8 notice header is emitted. +func TestApiSunsetWith_Good_AddsNoticeURLHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + + mw := api.ApiSunsetWith( + "2026-04-30", + "POST /api/v2/billing/invoices", + api.WithSunsetNoticeURL("https://docs.api.dappco.re/deprecation/billing"), + ) + + r := gin.New() + r.Use(mw) + r.GET("/billing", func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("ok")) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/billing", nil) + r.ServeHTTP(w, req) + + if got := w.Header().Get("API-Deprecation-Notice-URL"); got != "https://docs.api.dappco.re/deprecation/billing" { + t.Fatalf("expected API-Deprecation-Notice-URL header, got %q", got) + } + if got := w.Header().Get("API-Suggested-Replacement"); got != "POST /api/v2/billing/invoices" { + t.Fatalf("expected API-Suggested-Replacement to mirror replacement, got %q", got) + } +} + +// TestApiSunsetWith_Bad_OmitsEmptyOptionalHeaders ensures empty option values +// do not emit blank headers, keeping the response surface clean. +func TestApiSunsetWith_Bad_OmitsEmptyOptionalHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + mw := api.ApiSunsetWith("", "", api.WithSunsetNoticeURL(" ")) + + r := gin.New() + r.Use(mw) + r.GET("/x", func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("ok")) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(w, req) + + if got := w.Header().Get("Sunset"); got != "" { + t.Fatalf("expected no Sunset header for empty date, got %q", got) + } + if got := w.Header().Get("Link"); got != "" { + t.Fatalf("expected no Link header for empty replacement, got %q", got) + } + if got := w.Header().Get("API-Suggested-Replacement"); got != "" { + t.Fatalf("expected no API-Suggested-Replacement for empty replacement, got %q", got) + } + if got := w.Header().Get("API-Deprecation-Notice-URL"); got != "" { + t.Fatalf("expected no API-Deprecation-Notice-URL for blank URL, got %q", got) + } + if got := w.Header().Get("Deprecation"); got != "true" { + t.Fatalf("expected Deprecation=true even with no metadata, got %q", got) + } +} + func TestWithSunset_Good_PreservesExistingLinkHeaders(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/transport.go b/transport.go index 32943be..977361d 100644 --- a/transport.go +++ b/transport.go @@ -42,7 +42,7 @@ func (e *Engine) TransportConfig() TransportConfig { cfg := TransportConfig{ SwaggerEnabled: e.swaggerEnabled, - WSEnabled: e.wsHandler != nil, + WSEnabled: e.wsHandler != nil || e.wsGinHandler != nil, SSEEnabled: e.sseBroker != nil, PprofEnabled: e.pprofEnabled, ExpvarEnabled: e.expvarEnabled, @@ -58,7 +58,7 @@ func (e *Engine) TransportConfig() TransportConfig { if gql.Path != "" { cfg.GraphQLPath = gql.Path } - if e.wsHandler != nil || core.Trim(e.wsPath) != "" { + if e.wsHandler != nil || e.wsGinHandler != nil || core.Trim(e.wsPath) != "" { cfg.WSPath = resolveWSPath(e.wsPath) } if e.sseBroker != nil || core.Trim(e.ssePath) != "" { diff --git a/webhook.go b/webhook.go new file mode 100644 index 0000000..f865284 --- /dev/null +++ b/webhook.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net/http" + "strconv" + "time" + + core "dappco.re/go/core" +) + +// WebhookSigner produces and verifies HMAC-SHA256 signatures over webhook +// payloads. Spec §6: signed payloads (HMAC-SHA256) include a timestamp and +// a signature header so receivers can validate authenticity, integrity, and +// reject replays. +// +// The signature format mirrors the PHP-side WebhookSignature service: +// +// signature = HMAC-SHA256(timestamp + "." + payload, secret) +// +// signer := api.NewWebhookSigner("supersecret") +// headers := signer.Headers([]byte(`{"event":"workspace.created"}`)) +// // headers["X-Webhook-Signature"] is the hex digest +// // headers["X-Webhook-Timestamp"] is the Unix timestamp string +type WebhookSigner struct { + secret []byte + tolerance time.Duration +} + +const ( + // WebhookSignatureHeader is the response/request header that carries the + // HMAC-SHA256 hex digest of a signed webhook payload. + WebhookSignatureHeader = "X-Webhook-Signature" + + // WebhookTimestampHeader is the response/request header that carries the + // Unix timestamp used to derive the signature. + WebhookTimestampHeader = "X-Webhook-Timestamp" + + // DefaultWebhookTolerance is the maximum age of a webhook timestamp the + // signer will accept. Five minutes mirrors the PHP-side default and + // allows for reasonable clock skew between sender and receiver. + DefaultWebhookTolerance = 5 * time.Minute +) + +// NewWebhookSigner constructs a signer that uses the given shared secret with +// the default timestamp tolerance (five minutes). +// +// signer := api.NewWebhookSigner("shared-secret") +func NewWebhookSigner(secret string) *WebhookSigner { + return &WebhookSigner{ + secret: []byte(secret), + tolerance: DefaultWebhookTolerance, + } +} + +// NewWebhookSignerWithTolerance constructs a signer with a custom timestamp +// tolerance. A tolerance of zero or negative falls back to +// DefaultWebhookTolerance to avoid silently disabling replay protection. +// +// signer := api.NewWebhookSignerWithTolerance("secret", 30*time.Second) +func NewWebhookSignerWithTolerance(secret string, tolerance time.Duration) *WebhookSigner { + if tolerance <= 0 { + tolerance = DefaultWebhookTolerance + } + return &WebhookSigner{ + secret: []byte(secret), + tolerance: tolerance, + } +} + +// GenerateWebhookSecret returns a hex-encoded 32-byte random string suitable +// for use as a webhook signing secret. Output length is 64 characters. +// +// secret, err := api.GenerateWebhookSecret() +// // secret = "9f1a..." (64 hex chars) +func GenerateWebhookSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", core.E("WebhookSigner.GenerateSecret", "read random bytes", err) + } + return hex.EncodeToString(buf), nil +} + +// Tolerance returns the configured maximum age tolerance for verification. +// +// d := signer.Tolerance() +func (s *WebhookSigner) Tolerance() time.Duration { + if s == nil || s.tolerance <= 0 { + return DefaultWebhookTolerance + } + return s.tolerance +} + +// Sign returns the hex-encoded HMAC-SHA256 of "timestamp.payload" using the +// signer's secret. Callers may pass any non-nil payload bytes (typically a +// JSON-encoded event body). +// +// digest := signer.Sign(payload, time.Now().Unix()) +func (s *WebhookSigner) Sign(payload []byte, timestamp int64) string { + if s == nil { + return "" + } + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(strconv.FormatInt(timestamp, 10))) + mac.Write([]byte(".")) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +// SignNow signs the payload using the current Unix timestamp and returns +// both the signature and the timestamp used. +// +// sig, ts := signer.SignNow(payload) +func (s *WebhookSigner) SignNow(payload []byte) (string, int64) { + now := time.Now().Unix() + return s.Sign(payload, now), now +} + +// Headers returns the HTTP headers a sender should attach to a webhook +// request: X-Webhook-Signature and X-Webhook-Timestamp. The current Unix +// timestamp is used. +// +// for k, v := range signer.Headers(payload) { +// req.Header.Set(k, v) +// } +func (s *WebhookSigner) Headers(payload []byte) map[string]string { + sig, ts := s.SignNow(payload) + return map[string]string{ + WebhookSignatureHeader: sig, + WebhookTimestampHeader: strconv.FormatInt(ts, 10), + } +} + +// Verify reports whether the given signature matches the payload and the +// timestamp is within the signer's tolerance window. Comparison uses +// hmac.Equal to avoid timing attacks. +// +// if signer.Verify(payload, sig, ts) { +// // accept event +// } +func (s *WebhookSigner) Verify(payload []byte, signature string, timestamp int64) bool { + if s == nil { + return false + } + if !s.IsTimestampValid(timestamp) { + return false + } + expected := s.Sign(payload, timestamp) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// VerifySignatureOnly compares the signature without checking timestamp +// freshness. Use it when timestamp validation is performed elsewhere or in +// tests where a stable timestamp is required. +// +// ok := signer.VerifySignatureOnly(payload, sig, fixedTimestamp) +func (s *WebhookSigner) VerifySignatureOnly(payload []byte, signature string, timestamp int64) bool { + if s == nil { + return false + } + expected := s.Sign(payload, timestamp) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// IsTimestampValid reports whether the given Unix timestamp is within the +// signer's configured tolerance window relative to the current time. +// +// if !signer.IsTimestampValid(ts) { +// return errors.New("webhook timestamp expired") +// } +func (s *WebhookSigner) IsTimestampValid(timestamp int64) bool { + tol := s.Tolerance() + now := time.Now().Unix() + delta := now - timestamp + if delta < 0 { + delta = -delta + } + return time.Duration(delta)*time.Second <= tol +} + +// VerifyRequest extracts the signature and timestamp headers from a request +// and verifies the given payload against them. Returns false when the +// headers are missing, malformed, or the signature does not match. +// +// if !signer.VerifyRequest(r, payload) { +// http.Error(w, "invalid signature", http.StatusUnauthorized) +// return +// } +func (s *WebhookSigner) VerifyRequest(r *http.Request, payload []byte) bool { + if r == nil { + return false + } + sig := r.Header.Get(WebhookSignatureHeader) + rawTS := r.Header.Get(WebhookTimestampHeader) + if sig == "" || rawTS == "" { + return false + } + ts, err := strconv.ParseInt(rawTS, 10, 64) + if err != nil { + return false + } + return s.Verify(payload, sig, ts) +} diff --git a/webhook_test.go b/webhook_test.go new file mode 100644 index 0000000..b9769cb --- /dev/null +++ b/webhook_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// TestWebhook_NewWebhookSigner_Good_BuildsSignerWithDefaults verifies the +// constructor sets up a usable signer with the documented default tolerance. +func TestWebhook_NewWebhookSigner_Good_BuildsSignerWithDefaults(t *testing.T) { + s := NewWebhookSigner("hello") + if s == nil { + t.Fatal("expected non-nil signer") + } + if s.Tolerance() != DefaultWebhookTolerance { + t.Fatalf("expected default tolerance %s, got %s", DefaultWebhookTolerance, s.Tolerance()) + } +} + +// TestWebhook_NewWebhookSignerWithTolerance_Good_OverridesTolerance ensures the +// custom-tolerance constructor is honoured for positive durations. +func TestWebhook_NewWebhookSignerWithTolerance_Good_OverridesTolerance(t *testing.T) { + s := NewWebhookSignerWithTolerance("x", 30*time.Second) + if s.Tolerance() != 30*time.Second { + t.Fatalf("expected 30s tolerance, got %s", s.Tolerance()) + } +} + +// TestWebhook_NewWebhookSignerWithTolerance_Ugly_FallsBackOnZero verifies a +// non-positive tolerance falls back to the documented default rather than +// silently disabling replay protection. +func TestWebhook_NewWebhookSignerWithTolerance_Ugly_FallsBackOnZero(t *testing.T) { + s := NewWebhookSignerWithTolerance("x", 0) + if s.Tolerance() != DefaultWebhookTolerance { + t.Fatalf("expected default tolerance after zero override, got %s", s.Tolerance()) + } + s = NewWebhookSignerWithTolerance("x", -5*time.Minute) + if s.Tolerance() != DefaultWebhookTolerance { + t.Fatalf("expected default tolerance after negative override, got %s", s.Tolerance()) + } +} + +// TestWebhook_GenerateWebhookSecret_Good_Returns64HexChars ensures the helper +// returns a stable-format secret of the documented length. +func TestWebhook_GenerateWebhookSecret_Good_Returns64HexChars(t *testing.T) { + secret, err := GenerateWebhookSecret() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(secret) != 64 { + t.Fatalf("expected 64-char secret, got %d", len(secret)) + } + for _, r := range secret { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + t.Fatalf("expected lowercase hex characters, got %q", secret) + } + } +} + +// TestWebhook_Sign_Good_ProducesStableHexDigest ensures the sign helper is +// deterministic for the same payload, secret, and timestamp. +func TestWebhook_Sign_Good_ProducesStableHexDigest(t *testing.T) { + s := NewWebhookSigner("secret") + first := s.Sign([]byte("payload"), 1234567890) + second := s.Sign([]byte("payload"), 1234567890) + if first != second { + t.Fatalf("expected stable digest, got %s vs %s", first, second) + } + if len(first) != 64 { + t.Fatalf("expected 64-char hex digest, got %d", len(first)) + } +} + +// TestWebhook_Sign_Bad_ReturnsEmptyOnNilReceiver guards the nil-receiver +// behaviour required for safe defensive use in middleware. +func TestWebhook_Sign_Bad_ReturnsEmptyOnNilReceiver(t *testing.T) { + var s *WebhookSigner + if got := s.Sign([]byte("x"), 1); got != "" { + t.Fatalf("expected empty digest from nil receiver, got %q", got) + } +} + +// TestWebhook_SignNow_Good_RoundTripsCurrentTimestamp verifies SignNow returns +// a fresh timestamp that the verifier accepts. +func TestWebhook_SignNow_Good_RoundTripsCurrentTimestamp(t *testing.T) { + s := NewWebhookSigner("secret") + payload := []byte(`{"event":"workspace.created"}`) + sig, ts := s.SignNow(payload) + if !s.Verify(payload, sig, ts) { + t.Fatal("expected SignNow output to verify") + } +} + +// TestWebhook_Verify_Good_AcceptsMatchingSignature exercises the happy path of +// matching payload/signature/timestamp inside the tolerance window. +func TestWebhook_Verify_Good_AcceptsMatchingSignature(t *testing.T) { + s := NewWebhookSigner("secret") + payload := []byte("body") + now := time.Now().Unix() + sig := s.Sign(payload, now) + if !s.Verify(payload, sig, now) { + t.Fatal("expected valid signature to verify") + } +} + +// TestWebhook_Verify_Bad_RejectsTamperedPayload ensures payload mutation +// invalidates the signature even when the secret/timestamp are valid. +func TestWebhook_Verify_Bad_RejectsTamperedPayload(t *testing.T) { + s := NewWebhookSigner("secret") + now := time.Now().Unix() + sig := s.Sign([]byte("body"), now) + if s.Verify([]byte("tampered"), sig, now) { + t.Fatal("expected verification to fail for tampered payload") + } +} + +// TestWebhook_Verify_Bad_RejectsExpiredTimestamp ensures stale timestamps fail +// even when the signature itself is valid for the older timestamp. +func TestWebhook_Verify_Bad_RejectsExpiredTimestamp(t *testing.T) { + s := NewWebhookSignerWithTolerance("secret", time.Minute) + old := time.Now().Add(-2 * time.Minute).Unix() + sig := s.Sign([]byte("body"), old) + if s.Verify([]byte("body"), sig, old) { + t.Fatal("expected stale timestamp to be rejected") + } +} + +// TestWebhook_VerifySignatureOnly_Good_IgnoresExpiredTimestamp lets callers +// validate signature integrity even when timestamps fall outside tolerance. +func TestWebhook_VerifySignatureOnly_Good_IgnoresExpiredTimestamp(t *testing.T) { + s := NewWebhookSignerWithTolerance("secret", time.Second) + old := time.Now().Add(-time.Hour).Unix() + sig := s.Sign([]byte("body"), old) + if !s.VerifySignatureOnly([]byte("body"), sig, old) { + t.Fatal("expected signature-only verification to pass for expired timestamp") + } +} + +// TestWebhook_Headers_Good_PopulatesSignatureAndTimestamp verifies the header +// helper returns both the signature and the timestamp that produced it. +func TestWebhook_Headers_Good_PopulatesSignatureAndTimestamp(t *testing.T) { + s := NewWebhookSigner("secret") + headers := s.Headers([]byte("body")) + if headers[WebhookSignatureHeader] == "" { + t.Fatal("expected signature header to be set") + } + if headers[WebhookTimestampHeader] == "" { + t.Fatal("expected timestamp header to be set") + } + + ts, err := strconv.ParseInt(headers[WebhookTimestampHeader], 10, 64) + if err != nil { + t.Fatalf("expected numeric timestamp header, got %q", headers[WebhookTimestampHeader]) + } + if !s.Verify([]byte("body"), headers[WebhookSignatureHeader], ts) { + t.Fatal("expected Headers() output to verify") + } +} + +// TestWebhook_VerifyRequest_Good_AcceptsValidHeaders uses the request helper +// to ensure middleware can verify webhooks straight from an http.Request. +func TestWebhook_VerifyRequest_Good_AcceptsValidHeaders(t *testing.T) { + s := NewWebhookSigner("secret") + payload := []byte(`{"event":"link.clicked"}`) + headers := s.Headers(payload) + + r := httptest.NewRequest(http.MethodPost, "/incoming", strings.NewReader(string(payload))) + for k, v := range headers { + r.Header.Set(k, v) + } + if !s.VerifyRequest(r, payload) { + t.Fatal("expected VerifyRequest to accept valid signed request") + } +} + +// TestWebhook_VerifyRequest_Bad_RejectsMissingHeaders rejects requests with +// missing or malformed signature/timestamp headers. +func TestWebhook_VerifyRequest_Bad_RejectsMissingHeaders(t *testing.T) { + s := NewWebhookSigner("secret") + r := httptest.NewRequest(http.MethodPost, "/incoming", strings.NewReader("body")) + if s.VerifyRequest(r, []byte("body")) { + t.Fatal("expected VerifyRequest to fail with no headers") + } + + r.Header.Set(WebhookSignatureHeader, "deadbeef") + if s.VerifyRequest(r, []byte("body")) { + t.Fatal("expected VerifyRequest to fail with missing timestamp header") + } + + r.Header.Set(WebhookTimestampHeader, "not-a-number") + if s.VerifyRequest(r, []byte("body")) { + t.Fatal("expected VerifyRequest to fail with malformed timestamp header") + } +} + +// TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse documents the +// defensive nil-request guard so middleware can safely call this helper. +func TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse(t *testing.T) { + s := NewWebhookSigner("secret") + if s.VerifyRequest(nil, []byte("body")) { + t.Fatal("expected VerifyRequest(nil) to return false") + } +} + +// TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance exercises the +// inclusive boundary where the timestamp falls right at the tolerance edge. +func TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance(t *testing.T) { + s := NewWebhookSignerWithTolerance("x", time.Minute) + now := time.Now().Unix() + + if !s.IsTimestampValid(now) { + t.Fatal("expected current timestamp to be valid") + } + if !s.IsTimestampValid(now - 30) { + t.Fatal("expected timestamp within tolerance to be valid") + } + if s.IsTimestampValid(now - 120) { + t.Fatal("expected timestamp outside tolerance to be invalid") + } +} diff --git a/websocket_test.go b/websocket_test.go index 5d950b4..2e05f41 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -165,6 +165,95 @@ func TestWSEndpoint_Good_WithResponseMeta(t *testing.T) { } } +// TestWithWebSocket_Good_GinHandlerReceivesUpgrade verifies the gin-native +// WithWebSocket option mounts a *gin.Context-aware handler on /ws. +func TestWithWebSocket_Good_GinHandlerReceivesUpgrade(t *testing.T) { + gin.SetMode(gin.TestMode) + + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + handler := func(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + t.Logf("upgrade error: %v", err) + return + } + defer conn.Close() + _ = conn.WriteMessage(websocket.TextMessage, []byte("gin-hello")) + } + + e, err := api.New(api.WithWebSocket(handler)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial WebSocket: %v", err) + } + defer conn.Close() + + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read message: %v", err) + } + if string(msg) != "gin-hello" { + t.Fatalf("expected message=%q, got %q", "gin-hello", string(msg)) + } +} + +// TestWithWebSocket_Bad_NilHandlerNoMount ensures a nil handler is silently +// ignored rather than panicking on engine build. +func TestWithWebSocket_Bad_NilHandlerNoMount(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithWebSocket(nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ws", nil) + e.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /ws without handler, got %d", w.Code) + } +} + +// TestWithWebSocket_Ugly_GinHandlerWinsOverHTTPHandler verifies the gin form +// takes precedence when both options are supplied so callers can iteratively +// upgrade legacy registrations without behaviour drift. +func TestWithWebSocket_Ugly_GinHandlerWinsOverHTTPHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + called := "" + httpH := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = "http" + w.WriteHeader(http.StatusOK) + }) + ginH := func(c *gin.Context) { + called = "gin" + c.Status(http.StatusOK) + } + + e, err := api.New(api.WithWSHandler(httpH), api.WithWebSocket(ginH)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ws", nil) + e.Handler().ServeHTTP(w, req) + if called != "gin" { + t.Fatalf("expected gin handler to win, got %q", called) + } +} + func TestNoWSHandler_Good(t *testing.T) { gin.SetMode(gin.TestMode)