From 2b71c78c330759c1405c369273b94337d622b36a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 14:42:01 +0000 Subject: [PATCH] fix(openapi): ignore non-positive cache ttl in spec Co-Authored-By: Virgil --- cmd/api/cmd_test.go | 34 ++++++++++++++++++++++++++++++++++ openapi.go | 19 ++++++++++++++++++- openapi_test.go | 23 +++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go index 12fbd83..1f3b812 100644 --- a/cmd/api/cmd_test.go +++ b/cmd/api/cmd_test.go @@ -386,6 +386,40 @@ func TestNewSpecBuilder_Good_IgnoresCacheLimitsWithoutPositiveTTL(t *testing.T) } } +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) diff --git a/openapi.go b/openapi.go index c6e98b1..9c181c1 100644 --- a/openapi.go +++ b/openapi.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" "strings" + "time" "unicode" "slices" @@ -145,7 +146,7 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { if sb.CacheEnabled { spec["x-cache-enabled"] = true } - if ttl := strings.TrimSpace(sb.CacheTTL); ttl != "" { + if ttl := sb.effectiveCacheTTL(); ttl != "" { spec["x-cache-ttl"] = ttl } if sb.CacheMaxEntries > 0 { @@ -1937,6 +1938,22 @@ func (sb *SpecBuilder) effectiveSSEPath() string { return ssePath } +// effectiveCacheTTL returns a normalised cache TTL when it parses to a +// positive duration. +func (sb *SpecBuilder) effectiveCacheTTL() string { + ttl := strings.TrimSpace(sb.CacheTTL) + if ttl == "" { + return "" + } + + d, err := time.ParseDuration(ttl) + if err != nil || d <= 0 { + return "" + } + + return ttl +} + // effectiveAuthentikPublicPaths returns the public paths that Authentik skips // in practice, including the always-public health and Swagger endpoints. func (sb *SpecBuilder) effectiveAuthentikPublicPaths() []string { diff --git a/openapi_test.go b/openapi_test.go index 790aaa0..68c38b3 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -437,6 +437,29 @@ func TestSpecBuilder_Good_CacheAndI18nExtensions(t *testing.T) { } } +func TestSpecBuilder_Good_OmitsNonPositiveCacheTTLExtension(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Description: "Cache TTL test", + Version: "1.0.0", + CacheTTL: "0s", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if _, ok := spec["x-cache-ttl"]; ok { + t.Fatal("expected non-positive cache TTL to be omitted from spec") + } +} + func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",