// 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 {
		out, err := expandPattern(test.inPattern)
		if err != nil {
			t.Fatal(err)
		}
		sort.Strings(out)
		if !reflect.DeepEqual(out, test.outPathlets) {
			t.Fatalf("Test %d: Expected %v got %v", i, test.outPathlets, out)
		}
	}
}

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) {
	fields := map[string]*FieldSchema{
		"a": {Description: "path"},
		"b": {Description: "body"},
		"c": {Description: "body"},
		"d": {Description: "body"},
		"e": {Description: "path"},
	}

	pathFields, bodyFields := splitFields(fields, "some/{a}/path/{e}")

	lp := len(pathFields)
	lb := len(bodyFields)
	l := len(fields)
	if lp+lb != l {
		t.Fatalf("split length error: %d + %d != %d", lp, lb, 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 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,
			}
			specialPaths := &logical.Paths{
				Root:            test.rootPaths,
				Unauthenticated: test.unauthenticatedPaths,
			}

			if err := documentPath(&path, specialPaths, "kv", logical.TypeLogical, 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, nil, "kv", logical.TypeLogical, 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, sp, "kv", logical.TypeLogical, 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
}
