diff --git a/bundler/bundler_issue932_test.go b/bundler/bundler_issue932_test.go new file mode 100644 index 00000000..af21c126 --- /dev/null +++ b/bundler/bundler_issue932_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package bundler + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +func TestBundleBytesComposed_ExternalSecurityScheme(t *testing.T) { + tests := []struct { + name string + ref string + expectedComponent string + extraFiles map[string]string + }{ + { + name: "bare file", + ref: "./bearer-auth.yaml", + expectedComponent: "bearer-auth", + extraFiles: map[string]string{ + "bearer-auth.yaml": `type: http +scheme: bearer +bearerFormat: JWT +description: JWT bearer token. +`, + }, + }, + { + name: "component fragment", + ref: "./shared.yaml#/components/securitySchemes/bearerAuth", + expectedComponent: "bearerAuth__shared", + extraFiles: map[string]string{ + "shared.yaml": `components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT bearer token. +`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + for name, contents := range tt.extraFiles { + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, name), []byte(contents), 0o644)) + } + + root := `openapi: 3.2.0 +info: + title: Repro + version: 1.0.0 +paths: {} +components: + securitySchemes: + bearerAuth: + $ref: "` + tt.ref + `" +` + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + + bundled, err := BundleBytesComposed([]byte(root), config, nil) + require.NoError(t, err) + + output := string(bundled) + assert.Contains(t, output, `$ref: "#/components/securitySchemes/`+tt.expectedComponent+`"`) + assert.Contains(t, output, tt.expectedComponent+":\n type: http") + assert.Contains(t, output, "scheme: bearer") + assert.Contains(t, output, "bearerFormat: JWT") + assert.NotContains(t, output, "#/components/schemas/") + assert.NotContains(t, output, tt.ref) + }) + } +} diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index 07755abe..548d3f7e 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -292,6 +292,11 @@ func composeReferenceAs( return false, nil } return true, checkReferenceAndCapture(name, delimiter, v3low.MediaTypesLabel, pr, idx, components.MediaTypes, buildMediaType, cf.origins) + case v3low.SecuritySchemesLabel: + if components.SecuritySchemes == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.SecuritySchemesLabel, pr, idx, components.SecuritySchemes, buildSecurityScheme, cf.origins) default: return false, nil } @@ -365,6 +370,11 @@ func fileImportLocationForType( return false, nil } return true, handleFileImport(pr, v3low.MediaTypesLabel, delimiter, components.MediaTypes) + case v3low.SecuritySchemesLabel: + if components.SecuritySchemes == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.SecuritySchemesLabel, delimiter, components.SecuritySchemes) default: return false, nil } @@ -610,6 +620,13 @@ func buildResponse(node *yaml.Node, idx *index.SpecIndex) (*v3.Response, error) return v3.NewResponse(&resp), err } +func buildSecurityScheme(node *yaml.Node, idx *index.SpecIndex) (*v3.SecurityScheme, error) { + securityScheme := v3low.SecurityScheme{} + _ = low.BuildModel(node, &securityScheme) + err := securityScheme.Build(context.Background(), &yaml.Node{}, node, idx) + return v3.NewSecurityScheme(&securityScheme), err +} + func buildParameter(node *yaml.Node, idx *index.SpecIndex) (*v3.Parameter, error) { param := v3low.Parameter{} _ = low.BuildModel(node, ¶m) diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index 628c154a..c2b05e67 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -95,6 +95,7 @@ func TestComposeReferenceAs_MissingComponentMap(t *testing.T) { v3low.CallbacksLabel, v3low.PathItemsLabel, v3low.MediaTypesLabel, + v3low.SecuritySchemesLabel, "unknown", } { t.Run(componentType, func(t *testing.T) { @@ -148,6 +149,7 @@ func TestFileImportLocationForType_MissingComponentMap(t *testing.T) { v3low.CallbacksLabel, v3low.PathItemsLabel, v3low.MediaTypesLabel, + v3low.SecuritySchemesLabel, "unknown", } { t.Run(componentType, func(t *testing.T) { diff --git a/bundler/source_context.go b/bundler/source_context.go index a6b00c15..5b75c1aa 100644 --- a/bundler/source_context.go +++ b/bundler/source_context.go @@ -55,6 +55,8 @@ func inferComponentTypeFromSourcePath(sourcePath []string) (string, bool) { return v3.MediaTypesLabel, true case v3.ContentLabel: return v3.MediaTypesLabel, true + case v3.SecuritySchemesLabel: + return v3.SecuritySchemesLabel, true } if segment == v3.RequestBodyLabel { @@ -86,6 +88,9 @@ func canComposeContextualReference(componentType string, node *yaml.Node, bareFi if !bareFile { return true } + if componentType == v3.SecuritySchemesLabel { + return isSecuritySchemeNode(node) + } if detectedType, ok := DetectOpenAPIComponentType(node); ok { if detectedType == componentType { @@ -125,6 +130,25 @@ func canComposeContextualReference(componentType string, node *yaml.Node, bareFi } } +func isSecuritySchemeNode(node *yaml.Node) bool { + keys := getNodeKeys(node) + typeValue := getNodeValueForKey(node, v3.TypeLabel) + switch typeValue { + case "apiKey": + return containsKey(keys, v3.NameLabel) && containsKey(keys, v3.InLabel) + case "http": + return containsKey(keys, v3.SchemeLabel) + case "oauth2": + return containsKey(keys, v3.FlowsLabel) || containsKey(keys, v3.OAuth2MetadataUrlLabel) + case "openIdConnect": + return containsKey(keys, v3.OpenIdConnectUrlLabel) + case "mutualTLS": + return true + default: + return false + } +} + func unwrapDocumentNode(node *yaml.Node) *yaml.Node { if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { return node.Content[0] diff --git a/bundler/source_context_test.go b/bundler/source_context_test.go index c6b354e0..513e328a 100644 --- a/bundler/source_context_test.go +++ b/bundler/source_context_test.go @@ -132,6 +132,12 @@ func TestInferComponentTypeFromSourcePath(t *testing.T) { wantType: v3.MediaTypesLabel, wantOK: true, }, + { + name: "security scheme component", + sourcePath: []string{"components", "securitySchemes", "bearerAuth"}, + wantType: v3.SecuritySchemesLabel, + wantOK: true, + }, { name: "unknown path", sourcePath: []string{"x-private", "thing"}, @@ -249,9 +255,16 @@ func TestCanComposeContextualReference(t *testing.T) { want: false, }, { - name: "unknown component type is not composed", - componentType: "securitySchemes", - source: "description: Sparse security", + name: "bare file security scheme accepts HTTP scheme", + componentType: v3.SecuritySchemesLabel, + source: "type: http\nscheme: bearer\n", + bareFile: true, + want: true, + }, + { + name: "bare file security scheme rejects schema type", + componentType: v3.SecuritySchemesLabel, + source: "type: string\n", bareFile: true, want: false, }, @@ -271,3 +284,29 @@ func TestCanComposeContextualReference(t *testing.T) { func TestCanComposeContextualReference_NilNode(t *testing.T) { assert.False(t, canComposeContextualReference(v3.ResponsesLabel, nil, true)) } + +func TestIsSecuritySchemeNode(t *testing.T) { + tests := []struct { + name string + source string + want bool + }{ + {name: "api key", source: "type: apiKey\nname: X-API-Key\nin: header\n", want: true}, + {name: "http", source: "type: http\nscheme: bearer\n", want: true}, + {name: "oauth flows", source: "type: oauth2\nflows: {}\n", want: true}, + {name: "oauth metadata", source: "type: oauth2\noauth2MetadataUrl: https://example.com/oauth\n", want: true}, + {name: "openid connect", source: "type: openIdConnect\nopenIdConnectUrl: https://example.com/openid\n", want: true}, + {name: "mutual TLS", source: "type: mutualTLS\n", want: true}, + {name: "incomplete api key", source: "type: apiKey\nname: X-API-Key\n", want: false}, + {name: "incomplete HTTP", source: "type: http\n", want: false}, + {name: "schema", source: "type: string\n", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var document yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(tt.source), &document)) + assert.Equal(t, tt.want, isSecuritySchemeNode(unwrapDocumentNode(&document))) + }) + } +}