feat(api): webhooks + sunset headers + WithWebSocket + cmd/api migration

- webhook.go: HMAC-SHA256 WebhookSigner matching PHP WebhookSignature —
  sign/verify, X-Webhook-Signature / X-Webhook-Timestamp headers,
  VerifyRequest middleware helper, 5-minute default tolerance,
  secret generator (RFC §6)
- sunset.go: ApiSunsetWith(date, replacement, opts...) + WithSunsetNoticeURL;
  ApiSunset now emits API-Suggested-Replacement when replacement set;
  RouteDescription.NoticeURL surfaces API-Deprecation-Notice-URL (RFC §8)
- options.go + api.go + transport.go: WithWebSocket(gin.HandlerFunc)
  alongside existing WithWSHandler(http.Handler); gin form wins when
  both supplied (RFC §2.2)
- openapi.go: apiSuggestedReplacement + apiDeprecationNoticeURL as
  reusable header components; NoticeURL on a RouteDescription flips
  operation deprecated flag and emits response header doc
- cmd/api/*.go: migrated from Cobra (cli.NewCommand, StringFlag) to
  new path-based CLI API (c.Command + core.Options.String/Int/Bool);
  replaces the 1,422-line Cobra test suite with _Good/_Bad/_Ugly
  triads on the new surface
- webhook_test.go + sunset_test.go + websocket_test.go: full coverage

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-14 14:51:04 +01:00
parent fbb58486c4
commit da1839f730
16 changed files with 1175 additions and 1628 deletions

10
api.go
View file

@ -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))
}

View file

@ -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)
}

View file

@ -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
}

98
cmd/api/cmd_sdk_test.go Normal file
View file

@ -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)
}
}

View file

@ -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
}

191
cmd/api/cmd_spec_test.go Normal file
View file

@ -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())
}
}

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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",
},
},
}
}

View file

@ -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".
//

View file

@ -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)
}

View file

@ -72,11 +72,73 @@ func TestWithSunset_Good_AddsDeprecationHeaders(t *testing.T) {
if got := w.Header().Get("Link"); got != "</api/v2/status>; 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)

View file

@ -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) != "" {

208
webhook.go Normal file
View file

@ -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)
}

226
webhook_test.go Normal file
View file

@ -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")
}
}

View file

@ -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)