mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-08 15:47:00 +02:00
* OpenAPI: Fix generation of correct fields Currently, the OpenAPI generator logic is wrong about how it maps from Vault framework fields to OpenAPI. This manifests most obviously with endpoints making use of `framework.OptionalParamRegex` or similar regex-level optional path parameters, and results in various incorrect fields showing up in the generated request structures. The fix is a bit complicated, but in essence is just rewriting the OpenAPI logic to properly parallel the real request processing logic. With these changes: * A path parameter in an optional part of the regex, no longer gets erroneously treated as a body parameter when creating OpenAPI endpoints that do not include the optional parameter. * A field marked as `Query: true` no longer gets incorrectly skipped when creating OpenAPI `POST` operations. * changelog
951 lines
28 KiB
Go
951 lines
28 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package framework
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io/ioutil"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-test/deep"
|
|
"github.com/hashicorp/vault/sdk/helper/jsonutil"
|
|
"github.com/hashicorp/vault/sdk/helper/wrapping"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
func TestOpenAPI_Regex(t *testing.T) {
|
|
t.Run("Path fields", func(t *testing.T) {
|
|
input := `/foo/bar/{inner}/baz/{outer}`
|
|
|
|
matches := pathFieldsRe.FindAllStringSubmatch(input, -1)
|
|
|
|
exp1 := "inner"
|
|
exp2 := "outer"
|
|
if matches[0][1] != exp1 || matches[1][1] != exp2 {
|
|
t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches)
|
|
}
|
|
|
|
input = `/foo/bar/inner/baz/outer`
|
|
matches = pathFieldsRe.FindAllStringSubmatch(input, -1)
|
|
|
|
if matches != nil {
|
|
t.Fatalf("Expected nil match (%s), got %+v", input, matches)
|
|
}
|
|
})
|
|
t.Run("Filtering", func(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
regex *regexp.Regexp
|
|
output string
|
|
}{
|
|
{
|
|
input: `abcde`,
|
|
regex: wsRe,
|
|
output: "abcde",
|
|
},
|
|
{
|
|
input: ` a b cd e `,
|
|
regex: wsRe,
|
|
output: "abcde",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
result := test.regex.ReplaceAllString(test.input, "")
|
|
if result != test.output {
|
|
t.Fatalf("Clean Regex error (%s). Expected %s, got %s", test.input, test.output, result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestOpenAPI_ExpandPattern(t *testing.T) {
|
|
tests := []struct {
|
|
inPattern string
|
|
outPathlets []string
|
|
}{
|
|
// A simple string without regexp metacharacters passes through as is
|
|
{"rekey/backup", []string{"rekey/backup"}},
|
|
// A trailing regexp anchor metacharacter is removed
|
|
{"rekey/backup$", []string{"rekey/backup"}},
|
|
// As is a leading one
|
|
{"^rekey/backup", []string{"rekey/backup"}},
|
|
// Named capture groups become OpenAPI parameters
|
|
{"auth/(?P<path>.+?)/tune$", []string{"auth/{path}/tune"}},
|
|
{"auth/(?P<path>.+?)/tune/(?P<more>.*?)$", []string{"auth/{path}/tune/{more}"}},
|
|
// Even if the capture group contains very complex regexp structure inside it
|
|
{"something/(?P<something>(a|b(c|d))|e+|f{1,3}[ghi-k]?.*)", []string{"something/{something}"}},
|
|
// A question-mark results in a result without and with the optional path part
|
|
{"tools/hash(/(?P<urlalgorithm>.+))?", []string{
|
|
"tools/hash",
|
|
"tools/hash/{urlalgorithm}",
|
|
}},
|
|
// Multiple question-marks evaluate each possible combination
|
|
{"(leases/)?renew(/(?P<url_lease_id>.+))?", []string{
|
|
"leases/renew",
|
|
"leases/renew/{url_lease_id}",
|
|
"renew",
|
|
"renew/{url_lease_id}",
|
|
}},
|
|
// GenericNameRegex is one particular way of writing a named capture group, so behaves the same
|
|
{`config/ui/headers/` + GenericNameRegex("header"), []string{"config/ui/headers/{header}"}},
|
|
// The question-mark behaviour is still works when the question-mark is directly applied to a named capture group
|
|
{`leases/lookup/(?P<prefix>.+?)?`, []string{
|
|
"leases/lookup/",
|
|
"leases/lookup/{prefix}",
|
|
}},
|
|
// Optional trailing slashes at the end of the path get stripped - even if appearing deep inside an alternation
|
|
{`(raw/?$|raw/(?P<path>.+))`, []string{
|
|
"raw",
|
|
"raw/{path}",
|
|
}},
|
|
// OptionalParamRegex is also another way of writing a named capture group, that is optional
|
|
{"lookup" + OptionalParamRegex("urltoken"), []string{
|
|
"lookup",
|
|
"lookup/{urltoken}",
|
|
}},
|
|
// Optional trailign slashes at the end of the path get stripped in simpler cases too
|
|
{"roles/?$", []string{
|
|
"roles",
|
|
}},
|
|
{"roles/?", []string{
|
|
"roles",
|
|
}},
|
|
// Non-optional trailing slashes remain... although don't do this, it breaks HelpOperation!
|
|
// (Existing real examples of this pattern being fixed via https://github.com/hashicorp/vault/pull/18571)
|
|
{"accessors/$", []string{
|
|
"accessors/",
|
|
}},
|
|
// GenericNameRegex and OptionalParamRegex still work when concatenated
|
|
{"verify/" + GenericNameRegex("name") + OptionalParamRegex("urlalgorithm"), []string{
|
|
"verify/{name}",
|
|
"verify/{name}/{urlalgorithm}",
|
|
}},
|
|
// Named capture groups that specify enum-like parameters work as expected
|
|
{"^plugins/catalog/(?P<type>auth|database|secret)/(?P<name>.+)$", []string{
|
|
"plugins/catalog/{type}/{name}",
|
|
}},
|
|
{"^plugins/catalog/(?P<type>auth|database|secret)/?$", []string{
|
|
"plugins/catalog/{type}",
|
|
}},
|
|
// Alternations between various literal path segments work
|
|
{"(pathOne|pathTwo)/", []string{"pathOne/", "pathTwo/"}},
|
|
{"(pathOne|pathTwo)/" + GenericNameRegex("name"), []string{"pathOne/{name}", "pathTwo/{name}"}},
|
|
{
|
|
"(pathOne|path-2|Path_3)/" + GenericNameRegex("name"),
|
|
[]string{"Path_3/{name}", "path-2/{name}", "pathOne/{name}"},
|
|
},
|
|
// They still work when combined with GenericNameWithAtRegex
|
|
{"(creds|sts)/" + GenericNameWithAtRegex("name"), []string{
|
|
"creds/{name}",
|
|
"sts/{name}",
|
|
}},
|
|
// And when they're somewhere other than the start of the pattern
|
|
{"keys/generate/(internal|exported|kms)", []string{
|
|
"keys/generate/exported",
|
|
"keys/generate/internal",
|
|
"keys/generate/kms",
|
|
}},
|
|
// If a plugin author makes their list operation support both singular and plural forms, the OpenAPI notices
|
|
{"rolesets?/?", []string{"roleset", "rolesets"}},
|
|
// Complex nested alternation and question-marks are correctly interpreted
|
|
{"crl(/pem|/delta(/pem)?)?", []string{"crl", "crl/delta", "crl/delta/pem", "crl/pem"}},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
paths, _, err := expandPattern(test.inPattern)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sort.Strings(paths)
|
|
if !reflect.DeepEqual(paths, test.outPathlets) {
|
|
t.Fatalf("Test %d: Expected %v got %v", i, test.outPathlets, paths)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_ExpandPattern_ReturnsError(t *testing.T) {
|
|
tests := []struct {
|
|
inPattern string
|
|
outError error
|
|
}{
|
|
// None of these regexp constructs are allowed outside of named capture groups
|
|
{"[a-z]", errUnsupportableRegexpOperationForOpenAPI},
|
|
{".", errUnsupportableRegexpOperationForOpenAPI},
|
|
{"a+", errUnsupportableRegexpOperationForOpenAPI},
|
|
{"a*", errUnsupportableRegexpOperationForOpenAPI},
|
|
// So this pattern, which is a combination of two of the above isn't either - this pattern occurs in the KV
|
|
// secrets engine for its catch-all error handler, which provides a helpful hint to people treating a KV v2 as
|
|
// a KV v1.
|
|
{".*", errUnsupportableRegexpOperationForOpenAPI},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
_, _, err := expandPattern(test.inPattern)
|
|
if err != test.outError {
|
|
t.Fatalf("Test %d: Expected %q got %q", i, test.outError, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_SplitFields(t *testing.T) {
|
|
paths, captures, err := expandPattern("some/" + GenericNameRegex("a") + "/path" + OptionalParamRegex("e"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fields := map[string]*FieldSchema{
|
|
"a": {Description: "path"},
|
|
"b": {Description: "body"},
|
|
"c": {Description: "body"},
|
|
"d": {Description: "body"},
|
|
"e": {Description: "path"},
|
|
"f": {Description: "query", Query: true},
|
|
}
|
|
|
|
for index, path := range paths {
|
|
pathFields, queryFields, bodyFields := splitFields(fields, path, captures)
|
|
|
|
numPath := len(pathFields)
|
|
numQuery := len(queryFields)
|
|
numBody := len(bodyFields)
|
|
numExpectedDiscarded := 0
|
|
// The first path generated is expected to be the one omitting the optional parameter field "e"
|
|
if index == 0 {
|
|
numExpectedDiscarded = 1
|
|
}
|
|
l := len(fields)
|
|
if numPath+numQuery+numBody+numExpectedDiscarded != l {
|
|
t.Fatalf("split length error: %d + %d + %d + %d != %d", numPath, numQuery, numBody, numExpectedDiscarded, l)
|
|
}
|
|
|
|
for name, field := range pathFields {
|
|
if field.Description != "path" {
|
|
t.Fatalf("expected field %s to be in 'path', found in %s", name, field.Description)
|
|
}
|
|
}
|
|
for name, field := range queryFields {
|
|
if field.Description != "query" {
|
|
t.Fatalf("expected field %s to be in 'query', found in %s", name, field.Description)
|
|
}
|
|
}
|
|
for name, field := range bodyFields {
|
|
if field.Description != "body" {
|
|
t.Fatalf("expected field %s to be in 'body', found in %s", name, field.Description)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_SpecialPaths(t *testing.T) {
|
|
tests := map[string]struct {
|
|
pattern string
|
|
rootPaths []string
|
|
rootExpected bool
|
|
unauthenticatedPaths []string
|
|
unauthenticatedExpected bool
|
|
}{
|
|
"empty": {
|
|
pattern: "foo",
|
|
rootPaths: []string{},
|
|
rootExpected: false,
|
|
unauthenticatedPaths: []string{},
|
|
unauthenticatedExpected: false,
|
|
},
|
|
"exact-match-unauthenticated": {
|
|
pattern: "foo",
|
|
rootPaths: []string{},
|
|
rootExpected: false,
|
|
unauthenticatedPaths: []string{"foo"},
|
|
unauthenticatedExpected: true,
|
|
},
|
|
"exact-match-root": {
|
|
pattern: "foo",
|
|
rootPaths: []string{"foo"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"bar"},
|
|
unauthenticatedExpected: false,
|
|
},
|
|
"asterisk-match-unauthenticated": {
|
|
pattern: "foo/bar",
|
|
rootPaths: []string{"foo"},
|
|
rootExpected: false,
|
|
unauthenticatedPaths: []string{"foo/*"},
|
|
unauthenticatedExpected: true,
|
|
},
|
|
"asterisk-match-root": {
|
|
pattern: "foo/bar",
|
|
rootPaths: []string{"foo/*"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"foo"},
|
|
unauthenticatedExpected: false,
|
|
},
|
|
"path-ends-with-slash": {
|
|
pattern: "foo/",
|
|
rootPaths: []string{"foo/*"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"a", "b", "foo*"},
|
|
unauthenticatedExpected: true,
|
|
},
|
|
"asterisk-match-no-slash": {
|
|
pattern: "foo",
|
|
rootPaths: []string{"foo*"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"a", "fo*"},
|
|
unauthenticatedExpected: true,
|
|
},
|
|
"multiple-root-paths": {
|
|
pattern: "foo/bar",
|
|
rootPaths: []string{"a", "b", "foo/*"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"foo/baz/*"},
|
|
unauthenticatedExpected: false,
|
|
},
|
|
"plus-match-unauthenticated": {
|
|
pattern: "foo/bar/baz",
|
|
rootPaths: []string{"foo/bar"},
|
|
rootExpected: false,
|
|
unauthenticatedPaths: []string{"foo/+/baz"},
|
|
unauthenticatedExpected: true,
|
|
},
|
|
"plus-match-root": {
|
|
pattern: "foo/bar/baz",
|
|
rootPaths: []string{"foo/+/baz"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"foo/bar"},
|
|
unauthenticatedExpected: false,
|
|
},
|
|
"plus-and-asterisk": {
|
|
pattern: "foo/bar/baz/something",
|
|
rootPaths: []string{"foo/+/baz/*"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"foo/+/baz*"},
|
|
unauthenticatedExpected: true,
|
|
},
|
|
"double-plus-good": {
|
|
pattern: "foo/bar/baz",
|
|
rootPaths: []string{"foo/+/+"},
|
|
rootExpected: true,
|
|
unauthenticatedPaths: []string{"foo/bar"},
|
|
unauthenticatedExpected: false,
|
|
},
|
|
}
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
doc := NewOASDocument("version")
|
|
path := Path{
|
|
Pattern: test.pattern,
|
|
}
|
|
backend := &Backend{
|
|
PathsSpecial: &logical.Paths{
|
|
Root: test.rootPaths,
|
|
Unauthenticated: test.unauthenticatedPaths,
|
|
},
|
|
BackendType: logical.TypeLogical,
|
|
}
|
|
|
|
if err := documentPath(&path, backend, "kv", doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
actual := doc.Paths["/"+test.pattern].Sudo
|
|
if actual != test.rootExpected {
|
|
t.Fatalf("Test (root): expected: %v; got: %v", test.rootExpected, actual)
|
|
}
|
|
|
|
actual = doc.Paths["/"+test.pattern].Unauthenticated
|
|
if actual != test.unauthenticatedExpected {
|
|
t.Fatalf("Test (unauth): expected: %v; got: %v", test.unauthenticatedExpected, actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_Paths(t *testing.T) {
|
|
origDepth := deep.MaxDepth
|
|
defer func() { deep.MaxDepth = origDepth }()
|
|
deep.MaxDepth = 20
|
|
|
|
t.Run("Legacy callbacks", func(t *testing.T) {
|
|
p := &Path{
|
|
Pattern: "lookup/" + GenericNameRegex("id"),
|
|
|
|
Fields: map[string]*FieldSchema{
|
|
"id": {
|
|
Type: TypeString,
|
|
Description: "My id parameter",
|
|
},
|
|
"token": {
|
|
Type: TypeString,
|
|
Description: "My token",
|
|
},
|
|
},
|
|
|
|
Callbacks: map[logical.Operation]OperationFunc{
|
|
logical.ReadOperation: nil,
|
|
logical.UpdateOperation: nil,
|
|
},
|
|
|
|
HelpSynopsis: "Synopsis",
|
|
HelpDescription: "Description",
|
|
}
|
|
|
|
sp := &logical.Paths{
|
|
Root: []string{},
|
|
Unauthenticated: []string{},
|
|
}
|
|
testPath(t, p, sp, expected("legacy"))
|
|
})
|
|
|
|
t.Run("Operations - All Operations", func(t *testing.T) {
|
|
p := &Path{
|
|
Pattern: "foo/" + GenericNameRegex("id"),
|
|
Fields: map[string]*FieldSchema{
|
|
"id": {
|
|
Type: TypeString,
|
|
Description: "id path parameter",
|
|
},
|
|
"flavors": {
|
|
Type: TypeCommaStringSlice,
|
|
Description: "the flavors",
|
|
},
|
|
"name": {
|
|
Type: TypeNameString,
|
|
Default: "Larry",
|
|
Description: "the name",
|
|
},
|
|
"age": {
|
|
Type: TypeInt,
|
|
Description: "the age",
|
|
AllowedValues: []interface{}{1, 2, 3},
|
|
Required: true,
|
|
DisplayAttrs: &DisplayAttributes{
|
|
Name: "Age",
|
|
Sensitive: true,
|
|
Group: "Some Group",
|
|
Value: 7,
|
|
},
|
|
},
|
|
"x-abc-token": {
|
|
Type: TypeHeader,
|
|
Description: "a header value",
|
|
AllowedValues: []interface{}{"a", "b", "c"},
|
|
},
|
|
"maximum": {
|
|
Type: TypeInt64,
|
|
Description: "a maximum value",
|
|
},
|
|
"format": {
|
|
Type: TypeString,
|
|
Description: "a query param",
|
|
Query: true,
|
|
},
|
|
},
|
|
HelpSynopsis: "Synopsis",
|
|
HelpDescription: "Description",
|
|
Operations: map[logical.Operation]OperationHandler{
|
|
logical.ReadOperation: &PathOperation{
|
|
Summary: "My Summary",
|
|
Description: "My Description",
|
|
},
|
|
logical.UpdateOperation: &PathOperation{
|
|
Summary: "Update Summary",
|
|
Description: "Update Description",
|
|
},
|
|
logical.CreateOperation: &PathOperation{
|
|
Summary: "Create Summary",
|
|
Description: "Create Description",
|
|
},
|
|
logical.ListOperation: &PathOperation{
|
|
Summary: "List Summary",
|
|
Description: "List Description",
|
|
},
|
|
logical.DeleteOperation: &PathOperation{
|
|
Summary: "This shouldn't show up",
|
|
Unpublished: true,
|
|
},
|
|
},
|
|
DisplayAttrs: &DisplayAttributes{
|
|
Navigation: true,
|
|
},
|
|
}
|
|
|
|
sp := &logical.Paths{
|
|
Root: []string{"foo*"},
|
|
}
|
|
testPath(t, p, sp, expected("operations"))
|
|
})
|
|
|
|
t.Run("Operations - List Only", func(t *testing.T) {
|
|
p := &Path{
|
|
Pattern: "foo/" + GenericNameRegex("id"),
|
|
Fields: map[string]*FieldSchema{
|
|
"id": {
|
|
Type: TypeString,
|
|
Description: "id path parameter",
|
|
},
|
|
"flavors": {
|
|
Type: TypeCommaStringSlice,
|
|
Description: "the flavors",
|
|
},
|
|
"name": {
|
|
Type: TypeNameString,
|
|
Default: "Larry",
|
|
Description: "the name",
|
|
},
|
|
"age": {
|
|
Type: TypeInt,
|
|
Description: "the age",
|
|
AllowedValues: []interface{}{1, 2, 3},
|
|
Required: true,
|
|
DisplayAttrs: &DisplayAttributes{
|
|
Name: "Age",
|
|
Sensitive: true,
|
|
Group: "Some Group",
|
|
Value: 7,
|
|
},
|
|
},
|
|
"x-abc-token": {
|
|
Type: TypeHeader,
|
|
Description: "a header value",
|
|
AllowedValues: []interface{}{"a", "b", "c"},
|
|
},
|
|
"format": {
|
|
Type: TypeString,
|
|
Description: "a query param",
|
|
Query: true,
|
|
},
|
|
},
|
|
HelpSynopsis: "Synopsis",
|
|
HelpDescription: "Description",
|
|
Operations: map[logical.Operation]OperationHandler{
|
|
logical.ListOperation: &PathOperation{
|
|
Summary: "List Summary",
|
|
Description: "List Description",
|
|
},
|
|
},
|
|
DisplayAttrs: &DisplayAttributes{
|
|
Navigation: true,
|
|
},
|
|
}
|
|
|
|
sp := &logical.Paths{
|
|
Root: []string{"foo*"},
|
|
}
|
|
testPath(t, p, sp, expected("operations_list"))
|
|
})
|
|
|
|
t.Run("Responses", func(t *testing.T) {
|
|
p := &Path{
|
|
Pattern: "foo",
|
|
HelpSynopsis: "Synopsis",
|
|
HelpDescription: "Description",
|
|
Operations: map[logical.Operation]OperationHandler{
|
|
logical.ReadOperation: &PathOperation{
|
|
Summary: "My Summary",
|
|
Description: "My Description",
|
|
Responses: map[int][]Response{
|
|
202: {{
|
|
Description: "Amazing",
|
|
Example: &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"amount": 42,
|
|
},
|
|
},
|
|
Fields: map[string]*FieldSchema{
|
|
"field_a": {
|
|
Type: TypeString,
|
|
Description: "field_a description",
|
|
},
|
|
"field_b": {
|
|
Type: TypeBool,
|
|
Description: "field_b description",
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
logical.DeleteOperation: &PathOperation{
|
|
Summary: "Delete stuff",
|
|
},
|
|
},
|
|
}
|
|
|
|
sp := &logical.Paths{
|
|
Unauthenticated: []string{"x", "y", "foo"},
|
|
}
|
|
|
|
testPath(t, p, sp, expected("responses"))
|
|
})
|
|
}
|
|
|
|
func TestOpenAPI_CustomDecoder(t *testing.T) {
|
|
p := &Path{
|
|
Pattern: "foo",
|
|
HelpSynopsis: "Synopsis",
|
|
Operations: map[logical.Operation]OperationHandler{
|
|
logical.ReadOperation: &PathOperation{
|
|
Summary: "My Summary",
|
|
Responses: map[int][]Response{
|
|
100: {{
|
|
Description: "OK",
|
|
Example: &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"foo": 42,
|
|
},
|
|
},
|
|
}},
|
|
200: {{
|
|
Description: "Good",
|
|
Example: (*logical.Response)(nil),
|
|
}},
|
|
599: {{
|
|
Description: "Bad",
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
docOrig := NewOASDocument("version")
|
|
err := documentPath(p, &Backend{BackendType: logical.TypeLogical}, "kv", docOrig)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
docJSON := mustJSONMarshal(t, docOrig)
|
|
|
|
var intermediate map[string]interface{}
|
|
if err := jsonutil.DecodeJSON(docJSON, &intermediate); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
docNew, err := NewOASDocumentFromMap(intermediate)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
docNewJSON := mustJSONMarshal(t, docNew)
|
|
|
|
if diff := deep.Equal(docJSON, docNewJSON); diff != nil {
|
|
t.Fatal(diff)
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_CleanResponse(t *testing.T) {
|
|
// Verify that an all-null input results in empty JSON
|
|
orig := &logical.Response{}
|
|
|
|
cr := cleanResponse(orig)
|
|
|
|
newJSON := mustJSONMarshal(t, cr)
|
|
|
|
if !bytes.Equal(newJSON, []byte("{}")) {
|
|
t.Fatalf("expected {}, got: %q", newJSON)
|
|
}
|
|
|
|
// Verify that all non-null inputs results in JSON that matches the marshalling of
|
|
// logical.Response. This will fail if logical.Response changes without a corresponding
|
|
// change to cleanResponse()
|
|
orig = &logical.Response{
|
|
Secret: new(logical.Secret),
|
|
Auth: new(logical.Auth),
|
|
Data: map[string]interface{}{"foo": 42},
|
|
Redirect: "foo",
|
|
Warnings: []string{"foo"},
|
|
WrapInfo: &wrapping.ResponseWrapInfo{Token: "foo"},
|
|
Headers: map[string][]string{"foo": {"bar"}},
|
|
}
|
|
origJSON := mustJSONMarshal(t, orig)
|
|
|
|
cr = cleanResponse(orig)
|
|
|
|
cleanJSON := mustJSONMarshal(t, cr)
|
|
|
|
if diff := deep.Equal(origJSON, cleanJSON); diff != nil {
|
|
t.Fatal(diff)
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_constructOperationID(t *testing.T) {
|
|
tests := map[string]struct {
|
|
path string
|
|
pathIndex int
|
|
pathAttributes *DisplayAttributes
|
|
operation logical.Operation
|
|
operationAttributes *DisplayAttributes
|
|
defaultPrefix string
|
|
expected string
|
|
}{
|
|
"empty": {
|
|
path: "",
|
|
pathIndex: 0,
|
|
pathAttributes: nil,
|
|
operation: logical.Operation(""),
|
|
operationAttributes: nil,
|
|
defaultPrefix: "",
|
|
expected: "",
|
|
},
|
|
"simple-read": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: nil,
|
|
operation: logical.ReadOperation,
|
|
operationAttributes: nil,
|
|
defaultPrefix: "test",
|
|
expected: "test-read-path-to-thing",
|
|
},
|
|
"simple-write": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: nil,
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: nil,
|
|
defaultPrefix: "test",
|
|
expected: "test-write-path-to-thing",
|
|
},
|
|
"operation-verb": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationVerb: "do-something"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: nil,
|
|
defaultPrefix: "test",
|
|
expected: "do-something",
|
|
},
|
|
"operation-verb-override": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationVerb: "do-something"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationVerb: "do-something-else"},
|
|
defaultPrefix: "test",
|
|
expected: "do-something-else",
|
|
},
|
|
"operation-prefix": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: nil,
|
|
defaultPrefix: "test",
|
|
expected: "my-prefix-write-path-to-thing",
|
|
},
|
|
"operation-prefix-override": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-write-path-to-thing",
|
|
},
|
|
"operation-prefix-and-suffix": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: nil,
|
|
defaultPrefix: "test",
|
|
expected: "my-prefix-write-my-suffix",
|
|
},
|
|
"operation-prefix-and-suffix-override": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "better-suffix"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-write-better-suffix",
|
|
},
|
|
"operation-prefix-verb-suffix": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix", OperationVerb: "Create"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "better-suffix"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-create-better-suffix",
|
|
},
|
|
"operation-prefix-verb-suffix-override": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix", OperationVerb: "Create"},
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "better-suffix", OperationVerb: "Login"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-login-better-suffix",
|
|
},
|
|
"operation-prefix-verb": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: nil,
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationVerb: "Login"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-login",
|
|
},
|
|
"operation-verb-suffix": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: nil,
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationVerb: "Login", OperationSuffix: "better-suffix"},
|
|
defaultPrefix: "test",
|
|
expected: "login-better-suffix",
|
|
},
|
|
"pipe-delimited-suffix-0": {
|
|
path: "path/to/thing",
|
|
pathIndex: 0,
|
|
pathAttributes: nil,
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "suffix0|suffix1"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-write-suffix0",
|
|
},
|
|
"pipe-delimited-suffix-1": {
|
|
path: "path/to/thing",
|
|
pathIndex: 1,
|
|
pathAttributes: nil,
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "suffix0|suffix1"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-write-suffix1",
|
|
},
|
|
"pipe-delimited-suffix-2-fallback": {
|
|
path: "path/to/thing",
|
|
pathIndex: 2,
|
|
pathAttributes: nil,
|
|
operation: logical.UpdateOperation,
|
|
operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "suffix0|suffix1"},
|
|
defaultPrefix: "test",
|
|
expected: "better-prefix-write-path-to-thing",
|
|
},
|
|
}
|
|
|
|
for name, test := range tests {
|
|
name, test := name, test
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
actual := constructOperationID(
|
|
test.path,
|
|
test.pathIndex,
|
|
test.pathAttributes,
|
|
test.operation,
|
|
test.operationAttributes,
|
|
test.defaultPrefix,
|
|
)
|
|
if actual != test.expected {
|
|
t.Fatalf("expected: %s; got: %s", test.expected, actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAPI_hyphenatedToTitleCase(t *testing.T) {
|
|
tests := map[string]struct {
|
|
in string
|
|
expected string
|
|
}{
|
|
"simple": {
|
|
in: "test",
|
|
expected: "Test",
|
|
},
|
|
"two-words": {
|
|
in: "two-words",
|
|
expected: "TwoWords",
|
|
},
|
|
"three-words": {
|
|
in: "one-two-three",
|
|
expected: "OneTwoThree",
|
|
},
|
|
"not-hyphenated": {
|
|
in: "something_like_this",
|
|
expected: "Something_like_this",
|
|
},
|
|
}
|
|
|
|
for name, test := range tests {
|
|
name, test := name, test
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
actual := hyphenatedToTitleCase(test.in)
|
|
if actual != test.expected {
|
|
t.Fatalf("expected: %s; got: %s", test.expected, actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testPath(t *testing.T, path *Path, sp *logical.Paths, expectedJSON string) {
|
|
t.Helper()
|
|
|
|
doc := NewOASDocument("dummyversion")
|
|
if err := documentPath(path, &Backend{
|
|
PathsSpecial: sp,
|
|
BackendType: logical.TypeLogical,
|
|
}, "kv", doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc.CreateOperationIDs("")
|
|
|
|
docJSON, err := json.MarshalIndent(doc, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Compare json by first decoding, then comparing with a deep equality check.
|
|
var expected, actual interface{}
|
|
if err := jsonutil.DecodeJSON(docJSON, &actual); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := jsonutil.DecodeJSON([]byte(expectedJSON), &expected); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if diff := deep.Equal(actual, expected); diff != nil {
|
|
// fmt.Println(string(docJSON)) // uncomment to debug generated JSON (very helpful when fixing tests)
|
|
t.Fatal(diff)
|
|
}
|
|
}
|
|
|
|
func getPathOp(pi *OASPathItem, op string) *OASOperation {
|
|
switch op {
|
|
case "get":
|
|
return pi.Get
|
|
case "post":
|
|
return pi.Post
|
|
case "delete":
|
|
return pi.Delete
|
|
default:
|
|
panic("unexpected operation: " + op)
|
|
}
|
|
}
|
|
|
|
func expected(name string) string {
|
|
data, err := ioutil.ReadFile(filepath.Join("testdata", name+".json"))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
content := strings.Replace(string(data), "<vault_version>", "dummyversion", 1)
|
|
|
|
return content
|
|
}
|
|
|
|
func mustJSONMarshal(t *testing.T, data interface{}) []byte {
|
|
j, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return j
|
|
}
|