diff --git a/gen/internal_test.go b/gen/internal_test.go index 398322e..e0bc31e 100644 --- a/gen/internal_test.go +++ b/gen/internal_test.go @@ -114,6 +114,29 @@ func TestCoerceScalarArrayTable(t *testing.T) { } } +func TestParseSecurityReqTable(t *testing.T) { + cases := []struct { + want map[string][]string + in string + }{ + {map[string][]string{"ApiKeyAuth": nil}, "ApiKeyAuth"}, + {map[string][]string{"OAuth2Application": {"write", "admin"}}, "OAuth2Application[write, admin]"}, + // && combines schemes into a single requirement (all required together) + {map[string][]string{"ApiKeyAuth": nil, "BearerAuth": nil}, "ApiKeyAuth && BearerAuth"}, + {map[string][]string{"OAuth2Application": {"write"}, "ApiKeyAuth": nil}, "OAuth2Application[write] && ApiKeyAuth"}, + {map[string][]string{"A": nil, "B": nil}, " A && B "}, // whitespace tolerated + {map[string][]string{"A": nil}, "A && "}, // empty part skipped + {nil, "[write]"}, // malformed: missing scheme name + {nil, ""}, // no schemes: nil, never {} ("no auth") + {nil, " && "}, // separators only: nil as well + } + for _, c := range cases { + if got := parseSecurityReq(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("parseSecurityReq(%q) = %#v, want %#v", c.in, got, c.want) + } + } +} + func TestApplyValidationRulesTable(t *testing.T) { str := &Schema{Type: []string{"string"}} applyValidationRules(str, "min=3,max=10,len=4") @@ -160,6 +183,33 @@ func TestApplyValidationRulesTable(t *testing.T) { applyValidationRules(nil, "min=1") } +func TestMalformedSecurityEmitsNoRequirement(t *testing.T) { + // A nil/empty parse result must not be appended: a {} requirement would + // mean "no auth required". Guards live in applySecurity and parseOperation. + api := &API{SecuritySchemes: map[string]SecurityScheme{}, Extensions: map[string]any{}} + parseGeneral(parseCommentGroup(`@title T +@securitydefinitions.apikey K +@in header +@name Authorization +@security [write] +@security K`), api) + if len(api.Security) != 1 { + t.Fatalf("document security must keep only the valid requirement: %#v", api.Security) + } + if _, ok := api.Security[0]["K"]; !ok || len(api.Security[0]) != 1 { + t.Fatalf("document security requirement must reference exactly scheme K: %#v", api.Security[0]) + } + + res := &resolver{src: &source{}, schemas: map[string]*Schema{}} + op := parseOperation(parseCommentGroup(`@Router /x [get] +@Summary X +@Security [write] +@Security K`), res, refCtx{}) + if len(op.Security) != 1 { + t.Fatalf("operation security must keep only the valid requirement: %#v", op.Security) + } +} + func TestSchemeContextEndsAtNonSecurityDirective(t *testing.T) { api := &API{SecuritySchemes: map[string]SecurityScheme{}, Extensions: map[string]any{}} parseGeneral(parseCommentGroup(`@title T diff --git a/gen/parse.go b/gen/parse.go index 657d7a4..f95a686 100644 --- a/gen/parse.go +++ b/gen/parse.go @@ -207,7 +207,9 @@ func (p *generalParser) applySecurity(d directive) bool { case d.name == "security": // document-level security requirement — not a scheme attribute, so // it ends the scheme section like any other non-scheme directive - p.api.Security = append(p.api.Security, parseSecurityReq(d.args)) + if req := parseSecurityReq(d.args); len(req) > 0 { + p.api.Security = append(p.api.Security, req) + } p.lastScheme = nil case p.lastScheme == nil: return false @@ -295,7 +297,9 @@ func parseOperation(ds []directive, res *resolver, ctx refCtx) Operation { case "success", "failure", "response": op.Responses = append(op.Responses, parseResponses(d.args, res, ctx)...) case "security": - op.Security = append(op.Security, parseSecurityReq(d.args)) + if req := parseSecurityReq(d.args); len(req) > 0 { + op.Security = append(op.Security, req) + } case "header": attachHeader(&op, d.args) case "externaldocs.url": @@ -401,19 +405,34 @@ func splitMimeList(args string) []string { func parseSecurityReq(args string) map[string][]string { req := map[string][]string{} - // "OAuth2Application[write, admin]" or "ApiKeyAuth" - name := args - var scopes []string - if open := strings.IndexByte(args, '['); open >= 0 { - name = strings.TrimSpace(args[:open]) - inner := strings.Trim(args[open:], "[] ") - for s := range strings.SplitSeq(inner, ",") { - if s = strings.TrimSpace(s); s != "" { - scopes = append(scopes, s) + // One or more schemes combined with && (all required together, swag syntax): + // "OAuth2Application[write, admin]", "ApiKeyAuth" or "ApiKeyAuth && BearerAuth". + for part := range strings.SplitSeq(args, "&&") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name := part + var scopes []string + if open := strings.IndexByte(part, '['); open >= 0 { + name = strings.TrimSpace(part[:open]) + inner := strings.Trim(part[open:], "[] ") + for s := range strings.SplitSeq(inner, ",") { + if s = strings.TrimSpace(s); s != "" { + scopes = append(scopes, s) + } } } + if name == "" { + continue // malformed scope-only part like "[write]" - no scheme to require + } + req[name] = scopes + } + if len(req) == 0 { + // An empty Security Requirement Object ({}) would mean "no auth + // required" - never emit one for a malformed annotation. + return nil } - req[name] = scopes return req }