github.com/hashicorp/vault/sdk@v0.13.0/framework/openapi_test.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package framework 5 6 import ( 7 "bytes" 8 "encoding/json" 9 "io/ioutil" 10 "path/filepath" 11 "reflect" 12 "regexp" 13 "sort" 14 "strings" 15 "testing" 16 17 "github.com/go-test/deep" 18 "github.com/hashicorp/vault/sdk/helper/jsonutil" 19 "github.com/hashicorp/vault/sdk/helper/wrapping" 20 "github.com/hashicorp/vault/sdk/logical" 21 ) 22 23 func TestOpenAPI_Regex(t *testing.T) { 24 t.Run("Path fields", func(t *testing.T) { 25 input := `/foo/bar/{inner}/baz/{outer}` 26 27 matches := pathFieldsRe.FindAllStringSubmatch(input, -1) 28 29 exp1 := "inner" 30 exp2 := "outer" 31 if matches[0][1] != exp1 || matches[1][1] != exp2 { 32 t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches) 33 } 34 35 input = `/foo/bar/inner/baz/outer` 36 matches = pathFieldsRe.FindAllStringSubmatch(input, -1) 37 38 if matches != nil { 39 t.Fatalf("Expected nil match (%s), got %+v", input, matches) 40 } 41 }) 42 t.Run("Filtering", func(t *testing.T) { 43 tests := []struct { 44 input string 45 regex *regexp.Regexp 46 output string 47 }{ 48 { 49 input: `abcde`, 50 regex: wsRe, 51 output: "abcde", 52 }, 53 { 54 input: ` a b cd e `, 55 regex: wsRe, 56 output: "abcde", 57 }, 58 } 59 60 for _, test := range tests { 61 result := test.regex.ReplaceAllString(test.input, "") 62 if result != test.output { 63 t.Fatalf("Clean Regex error (%s). Expected %s, got %s", test.input, test.output, result) 64 } 65 } 66 }) 67 } 68 69 func TestOpenAPI_ExpandPattern(t *testing.T) { 70 tests := []struct { 71 inPattern string 72 outPathlets []string 73 }{ 74 // A simple string without regexp metacharacters passes through as is 75 {"rekey/backup", []string{"rekey/backup"}}, 76 // A trailing regexp anchor metacharacter is removed 77 {"rekey/backup$", []string{"rekey/backup"}}, 78 // As is a leading one 79 {"^rekey/backup", []string{"rekey/backup"}}, 80 // Named capture groups become OpenAPI parameters 81 {"auth/(?P<path>.+?)/tune$", []string{"auth/{path}/tune"}}, 82 {"auth/(?P<path>.+?)/tune/(?P<more>.*?)$", []string{"auth/{path}/tune/{more}"}}, 83 // Even if the capture group contains very complex regexp structure inside it 84 {"something/(?P<something>(a|b(c|d))|e+|f{1,3}[ghi-k]?.*)", []string{"something/{something}"}}, 85 // A question-mark results in a result without and with the optional path part 86 {"tools/hash(/(?P<urlalgorithm>.+))?", []string{ 87 "tools/hash", 88 "tools/hash/{urlalgorithm}", 89 }}, 90 // Multiple question-marks evaluate each possible combination 91 {"(leases/)?renew(/(?P<url_lease_id>.+))?", []string{ 92 "leases/renew", 93 "leases/renew/{url_lease_id}", 94 "renew", 95 "renew/{url_lease_id}", 96 }}, 97 // GenericNameRegex is one particular way of writing a named capture group, so behaves the same 98 {`config/ui/headers/` + GenericNameRegex("header"), []string{"config/ui/headers/{header}"}}, 99 // The question-mark behaviour is still works when the question-mark is directly applied to a named capture group 100 {`leases/lookup/(?P<prefix>.+?)?`, []string{ 101 "leases/lookup/", 102 "leases/lookup/{prefix}", 103 }}, 104 // Optional trailing slashes at the end of the path get stripped - even if appearing deep inside an alternation 105 {`(raw/?$|raw/(?P<path>.+))`, []string{ 106 "raw", 107 "raw/{path}", 108 }}, 109 // OptionalParamRegex is also another way of writing a named capture group, that is optional 110 {"lookup" + OptionalParamRegex("urltoken"), []string{ 111 "lookup", 112 "lookup/{urltoken}", 113 }}, 114 // Optional trailign slashes at the end of the path get stripped in simpler cases too 115 {"roles/?$", []string{ 116 "roles", 117 }}, 118 {"roles/?", []string{ 119 "roles", 120 }}, 121 // Non-optional trailing slashes remain... although don't do this, it breaks HelpOperation! 122 // (Existing real examples of this pattern being fixed via https://github.com/hashicorp/vault/pull/18571) 123 {"accessors/$", []string{ 124 "accessors/", 125 }}, 126 // GenericNameRegex and OptionalParamRegex still work when concatenated 127 {"verify/" + GenericNameRegex("name") + OptionalParamRegex("urlalgorithm"), []string{ 128 "verify/{name}", 129 "verify/{name}/{urlalgorithm}", 130 }}, 131 // Named capture groups that specify enum-like parameters work as expected 132 {"^plugins/catalog/(?P<type>auth|database|secret)/(?P<name>.+)$", []string{ 133 "plugins/catalog/{type}/{name}", 134 }}, 135 {"^plugins/catalog/(?P<type>auth|database|secret)/?$", []string{ 136 "plugins/catalog/{type}", 137 }}, 138 // Alternations between various literal path segments work 139 {"(pathOne|pathTwo)/", []string{"pathOne/", "pathTwo/"}}, 140 {"(pathOne|pathTwo)/" + GenericNameRegex("name"), []string{"pathOne/{name}", "pathTwo/{name}"}}, 141 { 142 "(pathOne|path-2|Path_3)/" + GenericNameRegex("name"), 143 []string{"Path_3/{name}", "path-2/{name}", "pathOne/{name}"}, 144 }, 145 // They still work when combined with GenericNameWithAtRegex 146 {"(creds|sts)/" + GenericNameWithAtRegex("name"), []string{ 147 "creds/{name}", 148 "sts/{name}", 149 }}, 150 // And when they're somewhere other than the start of the pattern 151 {"keys/generate/(internal|exported|kms)", []string{ 152 "keys/generate/exported", 153 "keys/generate/internal", 154 "keys/generate/kms", 155 }}, 156 // If a plugin author makes their list operation support both singular and plural forms, the OpenAPI notices 157 {"rolesets?/?", []string{"roleset", "rolesets"}}, 158 // Complex nested alternation and question-marks are correctly interpreted 159 {"crl(/pem|/delta(/pem)?)?", []string{"crl", "crl/delta", "crl/delta/pem", "crl/pem"}}, 160 } 161 162 for i, test := range tests { 163 paths, _, err := expandPattern(test.inPattern) 164 if err != nil { 165 t.Fatal(err) 166 } 167 sort.Strings(paths) 168 if !reflect.DeepEqual(paths, test.outPathlets) { 169 t.Fatalf("Test %d: Expected %v got %v", i, test.outPathlets, paths) 170 } 171 } 172 } 173 174 func TestOpenAPI_ExpandPattern_ReturnsError(t *testing.T) { 175 tests := []struct { 176 inPattern string 177 outError error 178 }{ 179 // None of these regexp constructs are allowed outside of named capture groups 180 {"[a-z]", errUnsupportableRegexpOperationForOpenAPI}, 181 {".", errUnsupportableRegexpOperationForOpenAPI}, 182 {"a+", errUnsupportableRegexpOperationForOpenAPI}, 183 {"a*", errUnsupportableRegexpOperationForOpenAPI}, 184 // So this pattern, which is a combination of two of the above isn't either - this pattern occurs in the KV 185 // secrets engine for its catch-all error handler, which provides a helpful hint to people treating a KV v2 as 186 // a KV v1. 187 {".*", errUnsupportableRegexpOperationForOpenAPI}, 188 } 189 190 for i, test := range tests { 191 _, _, err := expandPattern(test.inPattern) 192 if err != test.outError { 193 t.Fatalf("Test %d: Expected %q got %q", i, test.outError, err) 194 } 195 } 196 } 197 198 func TestOpenAPI_SplitFields(t *testing.T) { 199 paths, captures, err := expandPattern("some/" + GenericNameRegex("a") + "/path" + OptionalParamRegex("e")) 200 if err != nil { 201 t.Fatal(err) 202 } 203 204 fields := map[string]*FieldSchema{ 205 "a": {Description: "path"}, 206 "b": {Description: "body"}, 207 "c": {Description: "body"}, 208 "d": {Description: "body"}, 209 "e": {Description: "path"}, 210 "f": {Description: "query", Query: true}, 211 } 212 213 for index, path := range paths { 214 pathFields, queryFields, bodyFields := splitFields(fields, path, captures) 215 216 numPath := len(pathFields) 217 numQuery := len(queryFields) 218 numBody := len(bodyFields) 219 numExpectedDiscarded := 0 220 // The first path generated is expected to be the one omitting the optional parameter field "e" 221 if index == 0 { 222 numExpectedDiscarded = 1 223 } 224 l := len(fields) 225 if numPath+numQuery+numBody+numExpectedDiscarded != l { 226 t.Fatalf("split length error: %d + %d + %d + %d != %d", numPath, numQuery, numBody, numExpectedDiscarded, l) 227 } 228 229 for name, field := range pathFields { 230 if field.Description != "path" { 231 t.Fatalf("expected field %s to be in 'path', found in %s", name, field.Description) 232 } 233 } 234 for name, field := range queryFields { 235 if field.Description != "query" { 236 t.Fatalf("expected field %s to be in 'query', found in %s", name, field.Description) 237 } 238 } 239 for name, field := range bodyFields { 240 if field.Description != "body" { 241 t.Fatalf("expected field %s to be in 'body', found in %s", name, field.Description) 242 } 243 } 244 } 245 } 246 247 func TestOpenAPI_SpecialPaths(t *testing.T) { 248 tests := map[string]struct { 249 pattern string 250 rootPaths []string 251 rootExpected bool 252 unauthenticatedPaths []string 253 unauthenticatedExpected bool 254 }{ 255 "empty": { 256 pattern: "foo", 257 rootPaths: []string{}, 258 rootExpected: false, 259 unauthenticatedPaths: []string{}, 260 unauthenticatedExpected: false, 261 }, 262 "exact-match-unauthenticated": { 263 pattern: "foo", 264 rootPaths: []string{}, 265 rootExpected: false, 266 unauthenticatedPaths: []string{"foo"}, 267 unauthenticatedExpected: true, 268 }, 269 "exact-match-root": { 270 pattern: "foo", 271 rootPaths: []string{"foo"}, 272 rootExpected: true, 273 unauthenticatedPaths: []string{"bar"}, 274 unauthenticatedExpected: false, 275 }, 276 "asterisk-match-unauthenticated": { 277 pattern: "foo/bar", 278 rootPaths: []string{"foo"}, 279 rootExpected: false, 280 unauthenticatedPaths: []string{"foo/*"}, 281 unauthenticatedExpected: true, 282 }, 283 "asterisk-match-root": { 284 pattern: "foo/bar", 285 rootPaths: []string{"foo/*"}, 286 rootExpected: true, 287 unauthenticatedPaths: []string{"foo"}, 288 unauthenticatedExpected: false, 289 }, 290 "path-ends-with-slash": { 291 pattern: "foo/", 292 rootPaths: []string{"foo/*"}, 293 rootExpected: true, 294 unauthenticatedPaths: []string{"a", "b", "foo*"}, 295 unauthenticatedExpected: true, 296 }, 297 "asterisk-match-no-slash": { 298 pattern: "foo", 299 rootPaths: []string{"foo*"}, 300 rootExpected: true, 301 unauthenticatedPaths: []string{"a", "fo*"}, 302 unauthenticatedExpected: true, 303 }, 304 "multiple-root-paths": { 305 pattern: "foo/bar", 306 rootPaths: []string{"a", "b", "foo/*"}, 307 rootExpected: true, 308 unauthenticatedPaths: []string{"foo/baz/*"}, 309 unauthenticatedExpected: false, 310 }, 311 "plus-match-unauthenticated": { 312 pattern: "foo/bar/baz", 313 rootPaths: []string{"foo/bar"}, 314 rootExpected: false, 315 unauthenticatedPaths: []string{"foo/+/baz"}, 316 unauthenticatedExpected: true, 317 }, 318 "plus-match-root": { 319 pattern: "foo/bar/baz", 320 rootPaths: []string{"foo/+/baz"}, 321 rootExpected: true, 322 unauthenticatedPaths: []string{"foo/bar"}, 323 unauthenticatedExpected: false, 324 }, 325 "plus-and-asterisk": { 326 pattern: "foo/bar/baz/something", 327 rootPaths: []string{"foo/+/baz/*"}, 328 rootExpected: true, 329 unauthenticatedPaths: []string{"foo/+/baz*"}, 330 unauthenticatedExpected: true, 331 }, 332 "double-plus-good": { 333 pattern: "foo/bar/baz", 334 rootPaths: []string{"foo/+/+"}, 335 rootExpected: true, 336 unauthenticatedPaths: []string{"foo/bar"}, 337 unauthenticatedExpected: false, 338 }, 339 } 340 for name, test := range tests { 341 t.Run(name, func(t *testing.T) { 342 doc := NewOASDocument("version") 343 path := Path{ 344 Pattern: test.pattern, 345 } 346 backend := &Backend{ 347 PathsSpecial: &logical.Paths{ 348 Root: test.rootPaths, 349 Unauthenticated: test.unauthenticatedPaths, 350 }, 351 BackendType: logical.TypeLogical, 352 } 353 354 if err := documentPath(&path, backend, "kv", doc); err != nil { 355 t.Fatal(err) 356 } 357 358 actual := doc.Paths["/"+test.pattern].Sudo 359 if actual != test.rootExpected { 360 t.Fatalf("Test (root): expected: %v; got: %v", test.rootExpected, actual) 361 } 362 363 actual = doc.Paths["/"+test.pattern].Unauthenticated 364 if actual != test.unauthenticatedExpected { 365 t.Fatalf("Test (unauth): expected: %v; got: %v", test.unauthenticatedExpected, actual) 366 } 367 }) 368 } 369 } 370 371 func TestOpenAPI_Paths(t *testing.T) { 372 origDepth := deep.MaxDepth 373 defer func() { deep.MaxDepth = origDepth }() 374 deep.MaxDepth = 20 375 376 t.Run("Legacy callbacks", func(t *testing.T) { 377 p := &Path{ 378 Pattern: "lookup/" + GenericNameRegex("id"), 379 380 Fields: map[string]*FieldSchema{ 381 "id": { 382 Type: TypeString, 383 Description: "My id parameter", 384 }, 385 "token": { 386 Type: TypeString, 387 Description: "My token", 388 }, 389 }, 390 391 Callbacks: map[logical.Operation]OperationFunc{ 392 logical.ReadOperation: nil, 393 logical.UpdateOperation: nil, 394 }, 395 396 HelpSynopsis: "Synopsis", 397 HelpDescription: "Description", 398 } 399 400 sp := &logical.Paths{ 401 Root: []string{}, 402 Unauthenticated: []string{}, 403 } 404 testPath(t, p, sp, expected("legacy")) 405 }) 406 407 t.Run("Operations - All Operations", func(t *testing.T) { 408 p := &Path{ 409 Pattern: "foo/" + GenericNameRegex("id"), 410 Fields: map[string]*FieldSchema{ 411 "id": { 412 Type: TypeString, 413 Description: "id path parameter", 414 }, 415 "flavors": { 416 Type: TypeCommaStringSlice, 417 Description: "the flavors", 418 }, 419 "name": { 420 Type: TypeNameString, 421 Default: "Larry", 422 Description: "the name", 423 }, 424 "age": { 425 Type: TypeInt, 426 Description: "the age", 427 AllowedValues: []interface{}{1, 2, 3}, 428 Required: true, 429 DisplayAttrs: &DisplayAttributes{ 430 Name: "Age", 431 Sensitive: true, 432 Group: "Some Group", 433 Value: 7, 434 }, 435 }, 436 "x-abc-token": { 437 Type: TypeHeader, 438 Description: "a header value", 439 AllowedValues: []interface{}{"a", "b", "c"}, 440 }, 441 "maximum": { 442 Type: TypeInt64, 443 Description: "a maximum value", 444 }, 445 "format": { 446 Type: TypeString, 447 Description: "a query param", 448 Query: true, 449 }, 450 }, 451 HelpSynopsis: "Synopsis", 452 HelpDescription: "Description", 453 Operations: map[logical.Operation]OperationHandler{ 454 logical.ReadOperation: &PathOperation{ 455 Summary: "My Summary", 456 Description: "My Description", 457 }, 458 logical.UpdateOperation: &PathOperation{ 459 Summary: "Update Summary", 460 Description: "Update Description", 461 }, 462 logical.CreateOperation: &PathOperation{ 463 Summary: "Create Summary", 464 Description: "Create Description", 465 }, 466 logical.ListOperation: &PathOperation{ 467 Summary: "List Summary", 468 Description: "List Description", 469 }, 470 logical.DeleteOperation: &PathOperation{ 471 Summary: "This shouldn't show up", 472 Unpublished: true, 473 }, 474 }, 475 DisplayAttrs: &DisplayAttributes{ 476 Navigation: true, 477 }, 478 } 479 480 sp := &logical.Paths{ 481 Root: []string{"foo*"}, 482 } 483 testPath(t, p, sp, expected("operations")) 484 }) 485 486 t.Run("Operations - List Only", func(t *testing.T) { 487 p := &Path{ 488 Pattern: "foo/" + GenericNameRegex("id"), 489 Fields: map[string]*FieldSchema{ 490 "id": { 491 Type: TypeString, 492 Description: "id path parameter", 493 }, 494 "flavors": { 495 Type: TypeCommaStringSlice, 496 Description: "the flavors", 497 }, 498 "name": { 499 Type: TypeNameString, 500 Default: "Larry", 501 Description: "the name", 502 }, 503 "age": { 504 Type: TypeInt, 505 Description: "the age", 506 AllowedValues: []interface{}{1, 2, 3}, 507 Required: true, 508 DisplayAttrs: &DisplayAttributes{ 509 Name: "Age", 510 Sensitive: true, 511 Group: "Some Group", 512 Value: 7, 513 }, 514 }, 515 "x-abc-token": { 516 Type: TypeHeader, 517 Description: "a header value", 518 AllowedValues: []interface{}{"a", "b", "c"}, 519 }, 520 "format": { 521 Type: TypeString, 522 Description: "a query param", 523 Query: true, 524 }, 525 }, 526 HelpSynopsis: "Synopsis", 527 HelpDescription: "Description", 528 Operations: map[logical.Operation]OperationHandler{ 529 logical.ListOperation: &PathOperation{ 530 Summary: "List Summary", 531 Description: "List Description", 532 }, 533 }, 534 DisplayAttrs: &DisplayAttributes{ 535 Navigation: true, 536 }, 537 } 538 539 sp := &logical.Paths{ 540 Root: []string{"foo*"}, 541 } 542 testPath(t, p, sp, expected("operations_list")) 543 }) 544 545 t.Run("Responses", func(t *testing.T) { 546 p := &Path{ 547 Pattern: "foo", 548 HelpSynopsis: "Synopsis", 549 HelpDescription: "Description", 550 Operations: map[logical.Operation]OperationHandler{ 551 logical.ReadOperation: &PathOperation{ 552 Summary: "My Summary", 553 Description: "My Description", 554 Responses: map[int][]Response{ 555 202: {{ 556 Description: "Amazing", 557 Example: &logical.Response{ 558 Data: map[string]interface{}{ 559 "amount": 42, 560 }, 561 }, 562 Fields: map[string]*FieldSchema{ 563 "field_a": { 564 Type: TypeString, 565 Description: "field_a description", 566 }, 567 "field_b": { 568 Type: TypeBool, 569 Description: "field_b description", 570 }, 571 }, 572 }}, 573 }, 574 }, 575 logical.DeleteOperation: &PathOperation{ 576 Summary: "Delete stuff", 577 }, 578 }, 579 } 580 581 sp := &logical.Paths{ 582 Unauthenticated: []string{"x", "y", "foo"}, 583 } 584 585 testPath(t, p, sp, expected("responses")) 586 }) 587 } 588 589 func TestOpenAPI_CustomDecoder(t *testing.T) { 590 p := &Path{ 591 Pattern: "foo", 592 HelpSynopsis: "Synopsis", 593 Operations: map[logical.Operation]OperationHandler{ 594 logical.ReadOperation: &PathOperation{ 595 Summary: "My Summary", 596 Responses: map[int][]Response{ 597 100: {{ 598 Description: "OK", 599 Example: &logical.Response{ 600 Data: map[string]interface{}{ 601 "foo": 42, 602 }, 603 }, 604 }}, 605 200: {{ 606 Description: "Good", 607 Example: (*logical.Response)(nil), 608 }}, 609 599: {{ 610 Description: "Bad", 611 }}, 612 }, 613 }, 614 }, 615 } 616 617 docOrig := NewOASDocument("version") 618 err := documentPath(p, &Backend{BackendType: logical.TypeLogical}, "kv", docOrig) 619 if err != nil { 620 t.Fatal(err) 621 } 622 623 docJSON := mustJSONMarshal(t, docOrig) 624 625 var intermediate map[string]interface{} 626 if err := jsonutil.DecodeJSON(docJSON, &intermediate); err != nil { 627 t.Fatal(err) 628 } 629 630 docNew, err := NewOASDocumentFromMap(intermediate) 631 if err != nil { 632 t.Fatal(err) 633 } 634 635 docNewJSON := mustJSONMarshal(t, docNew) 636 637 if diff := deep.Equal(docJSON, docNewJSON); diff != nil { 638 t.Fatal(diff) 639 } 640 } 641 642 func TestOpenAPI_CleanResponse(t *testing.T) { 643 // Verify that an all-null input results in empty JSON 644 orig := &logical.Response{} 645 646 cr := cleanResponse(orig) 647 648 newJSON := mustJSONMarshal(t, cr) 649 650 if !bytes.Equal(newJSON, []byte("{}")) { 651 t.Fatalf("expected {}, got: %q", newJSON) 652 } 653 654 // Verify that all non-null inputs results in JSON that matches the marshalling of 655 // logical.Response. This will fail if logical.Response changes without a corresponding 656 // change to cleanResponse() 657 orig = &logical.Response{ 658 Secret: new(logical.Secret), 659 Auth: new(logical.Auth), 660 Data: map[string]interface{}{"foo": 42}, 661 Redirect: "foo", 662 Warnings: []string{"foo"}, 663 WrapInfo: &wrapping.ResponseWrapInfo{Token: "foo"}, 664 Headers: map[string][]string{"foo": {"bar"}}, 665 MountType: "mount", 666 } 667 origJSON := mustJSONMarshal(t, orig) 668 669 cr = cleanResponse(orig) 670 671 cleanJSON := mustJSONMarshal(t, cr) 672 673 if diff := deep.Equal(origJSON, cleanJSON); diff != nil { 674 t.Fatal(diff) 675 } 676 } 677 678 func TestOpenAPI_constructOperationID(t *testing.T) { 679 tests := map[string]struct { 680 path string 681 pathIndex int 682 pathAttributes *DisplayAttributes 683 operation logical.Operation 684 operationAttributes *DisplayAttributes 685 defaultPrefix string 686 expected string 687 }{ 688 "empty": { 689 path: "", 690 pathIndex: 0, 691 pathAttributes: nil, 692 operation: logical.Operation(""), 693 operationAttributes: nil, 694 defaultPrefix: "", 695 expected: "", 696 }, 697 "simple-read": { 698 path: "path/to/thing", 699 pathIndex: 0, 700 pathAttributes: nil, 701 operation: logical.ReadOperation, 702 operationAttributes: nil, 703 defaultPrefix: "test", 704 expected: "test-read-path-to-thing", 705 }, 706 "simple-write": { 707 path: "path/to/thing", 708 pathIndex: 0, 709 pathAttributes: nil, 710 operation: logical.UpdateOperation, 711 operationAttributes: nil, 712 defaultPrefix: "test", 713 expected: "test-write-path-to-thing", 714 }, 715 "operation-verb": { 716 path: "path/to/thing", 717 pathIndex: 0, 718 pathAttributes: &DisplayAttributes{OperationVerb: "do-something"}, 719 operation: logical.UpdateOperation, 720 operationAttributes: nil, 721 defaultPrefix: "test", 722 expected: "do-something", 723 }, 724 "operation-verb-override": { 725 path: "path/to/thing", 726 pathIndex: 0, 727 pathAttributes: &DisplayAttributes{OperationVerb: "do-something"}, 728 operation: logical.UpdateOperation, 729 operationAttributes: &DisplayAttributes{OperationVerb: "do-something-else"}, 730 defaultPrefix: "test", 731 expected: "do-something-else", 732 }, 733 "operation-prefix": { 734 path: "path/to/thing", 735 pathIndex: 0, 736 pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix"}, 737 operation: logical.UpdateOperation, 738 operationAttributes: nil, 739 defaultPrefix: "test", 740 expected: "my-prefix-write-path-to-thing", 741 }, 742 "operation-prefix-override": { 743 path: "path/to/thing", 744 pathIndex: 0, 745 pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix"}, 746 operation: logical.UpdateOperation, 747 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix"}, 748 defaultPrefix: "test", 749 expected: "better-prefix-write-path-to-thing", 750 }, 751 "operation-prefix-and-suffix": { 752 path: "path/to/thing", 753 pathIndex: 0, 754 pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix"}, 755 operation: logical.UpdateOperation, 756 operationAttributes: nil, 757 defaultPrefix: "test", 758 expected: "my-prefix-write-my-suffix", 759 }, 760 "operation-prefix-and-suffix-override": { 761 path: "path/to/thing", 762 pathIndex: 0, 763 pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix"}, 764 operation: logical.UpdateOperation, 765 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "better-suffix"}, 766 defaultPrefix: "test", 767 expected: "better-prefix-write-better-suffix", 768 }, 769 "operation-prefix-verb-suffix": { 770 path: "path/to/thing", 771 pathIndex: 0, 772 pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix", OperationVerb: "Create"}, 773 operation: logical.UpdateOperation, 774 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "better-suffix"}, 775 defaultPrefix: "test", 776 expected: "better-prefix-create-better-suffix", 777 }, 778 "operation-prefix-verb-suffix-override": { 779 path: "path/to/thing", 780 pathIndex: 0, 781 pathAttributes: &DisplayAttributes{OperationPrefix: "my-prefix", OperationSuffix: "my-suffix", OperationVerb: "Create"}, 782 operation: logical.UpdateOperation, 783 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "better-suffix", OperationVerb: "Login"}, 784 defaultPrefix: "test", 785 expected: "better-prefix-login-better-suffix", 786 }, 787 "operation-prefix-verb": { 788 path: "path/to/thing", 789 pathIndex: 0, 790 pathAttributes: nil, 791 operation: logical.UpdateOperation, 792 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationVerb: "Login"}, 793 defaultPrefix: "test", 794 expected: "better-prefix-login", 795 }, 796 "operation-verb-suffix": { 797 path: "path/to/thing", 798 pathIndex: 0, 799 pathAttributes: nil, 800 operation: logical.UpdateOperation, 801 operationAttributes: &DisplayAttributes{OperationVerb: "Login", OperationSuffix: "better-suffix"}, 802 defaultPrefix: "test", 803 expected: "login-better-suffix", 804 }, 805 "pipe-delimited-suffix-0": { 806 path: "path/to/thing", 807 pathIndex: 0, 808 pathAttributes: nil, 809 operation: logical.UpdateOperation, 810 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "suffix0|suffix1"}, 811 defaultPrefix: "test", 812 expected: "better-prefix-write-suffix0", 813 }, 814 "pipe-delimited-suffix-1": { 815 path: "path/to/thing", 816 pathIndex: 1, 817 pathAttributes: nil, 818 operation: logical.UpdateOperation, 819 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "suffix0|suffix1"}, 820 defaultPrefix: "test", 821 expected: "better-prefix-write-suffix1", 822 }, 823 "pipe-delimited-suffix-2-fallback": { 824 path: "path/to/thing", 825 pathIndex: 2, 826 pathAttributes: nil, 827 operation: logical.UpdateOperation, 828 operationAttributes: &DisplayAttributes{OperationPrefix: "better-prefix", OperationSuffix: "suffix0|suffix1"}, 829 defaultPrefix: "test", 830 expected: "better-prefix-write-path-to-thing", 831 }, 832 } 833 834 for name, test := range tests { 835 name, test := name, test 836 t.Run(name, func(t *testing.T) { 837 t.Parallel() 838 actual := constructOperationID( 839 test.path, 840 test.pathIndex, 841 test.pathAttributes, 842 test.operation, 843 test.operationAttributes, 844 test.defaultPrefix, 845 ) 846 if actual != test.expected { 847 t.Fatalf("expected: %s; got: %s", test.expected, actual) 848 } 849 }) 850 } 851 } 852 853 func TestOpenAPI_hyphenatedToTitleCase(t *testing.T) { 854 tests := map[string]struct { 855 in string 856 expected string 857 }{ 858 "simple": { 859 in: "test", 860 expected: "Test", 861 }, 862 "two-words": { 863 in: "two-words", 864 expected: "TwoWords", 865 }, 866 "three-words": { 867 in: "one-two-three", 868 expected: "OneTwoThree", 869 }, 870 "not-hyphenated": { 871 in: "something_like_this", 872 expected: "Something_like_this", 873 }, 874 } 875 876 for name, test := range tests { 877 name, test := name, test 878 t.Run(name, func(t *testing.T) { 879 t.Parallel() 880 actual := hyphenatedToTitleCase(test.in) 881 if actual != test.expected { 882 t.Fatalf("expected: %s; got: %s", test.expected, actual) 883 } 884 }) 885 } 886 } 887 888 func testPath(t *testing.T, path *Path, sp *logical.Paths, expectedJSON string) { 889 t.Helper() 890 891 doc := NewOASDocument("dummyversion") 892 if err := documentPath(path, &Backend{ 893 PathsSpecial: sp, 894 BackendType: logical.TypeLogical, 895 }, "kv", doc); err != nil { 896 t.Fatal(err) 897 } 898 doc.CreateOperationIDs("") 899 900 docJSON, err := json.MarshalIndent(doc, "", " ") 901 if err != nil { 902 t.Fatal(err) 903 } 904 // Compare json by first decoding, then comparing with a deep equality check. 905 var expected, actual interface{} 906 if err := jsonutil.DecodeJSON(docJSON, &actual); err != nil { 907 t.Fatal(err) 908 } 909 910 if err := jsonutil.DecodeJSON([]byte(expectedJSON), &expected); err != nil { 911 t.Fatal(err) 912 } 913 914 if diff := deep.Equal(actual, expected); diff != nil { 915 // fmt.Println(string(docJSON)) // uncomment to debug generated JSON (very helpful when fixing tests) 916 t.Fatal(diff) 917 } 918 } 919 920 func getPathOp(pi *OASPathItem, op string) *OASOperation { 921 switch op { 922 case "get": 923 return pi.Get 924 case "post": 925 return pi.Post 926 case "delete": 927 return pi.Delete 928 default: 929 panic("unexpected operation: " + op) 930 } 931 } 932 933 func expected(name string) string { 934 data, err := ioutil.ReadFile(filepath.Join("testdata", name+".json")) 935 if err != nil { 936 panic(err) 937 } 938 939 content := strings.Replace(string(data), "<vault_version>", "dummyversion", 1) 940 941 return content 942 } 943 944 func mustJSONMarshal(t *testing.T, data interface{}) []byte { 945 j, err := json.MarshalIndent(data, "", " ") 946 if err != nil { 947 t.Fatal(err) 948 } 949 return j 950 }