Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions bundler/bundler_issue932_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
17 changes: 17 additions & 0 deletions bundler/composer_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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, &param)
Expand Down
2 changes: 2 additions & 0 deletions bundler/composer_functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions bundler/source_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
45 changes: 42 additions & 3 deletions bundler/source_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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,
},
Expand All @@ -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)))
})
}
}
Loading