Skip to content

Commit 7dd4764

Browse files
committed
Fixed openapi.json, added more tests for openapi.json validation
1 parent 6af3b52 commit 7dd4764

3 files changed

Lines changed: 245 additions & 76 deletions

File tree

internal/webserver/api/openapi_test.go

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import (
55
"fmt"
66
"os"
77
"reflect"
8+
"slices"
89
"strings"
910
"testing"
11+
12+
"github.com/forceu/gokapi/internal/models"
1013
)
1114

1215
// OpenAPI specification structures
@@ -23,7 +26,8 @@ type PathItem struct {
2326
}
2427

2528
type Operation struct {
26-
Parameters []Parameter `json:"parameters,omitempty"`
29+
Parameters []Parameter `json:"parameters,omitempty"`
30+
Security []map[string][]string `json:"security,omitempty"`
2731
}
2832

2933
type Parameter struct {
@@ -58,6 +62,12 @@ func TestOpenAPISpecification(t *testing.T) {
5862
// 3. Check for extra paths in OpenAPI that don't exist in routes
5963
failures = append(failures, validateNoExtraPaths(spec)...)
6064

65+
// 4. Check that all security scopes are valid API permission names
66+
failures = append(failures, validateSecurityScopes(spec)...)
67+
68+
// 5 & 6. Check that header schema types in OpenAPI match the Go field types
69+
failures = append(failures, validateHeaderTypes(spec)...)
70+
6171
// Report results
6272
if len(failures) > 0 {
6373
t.Errorf("OpenAPI validation failed with %d error(s):\n%s",
@@ -283,6 +293,7 @@ type HeaderInfo struct {
283293
Required bool
284294
Unpublished bool
285295
SupportBase64 bool
296+
GoType string // "string", "boolean", or "integer"
286297
}
287298

288299
// extractHeadersFromParser uses reflection to extract header information
@@ -315,11 +326,22 @@ func extractHeadersFromParser(parser requestParser) map[string]HeaderInfo {
315326
// Check if supports base64
316327
supportBase64 := field.Tag.Get("supportBase64") == "true"
317328

329+
// Map the Go kind to the OpenAPI type string
330+
goType := "string"
331+
switch field.Type.Kind() {
332+
case reflect.Bool:
333+
goType = "boolean"
334+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
335+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
336+
goType = "integer"
337+
}
338+
318339
headers[headerTag] = HeaderInfo{
319340
Name: headerTag,
320341
Required: required,
321342
SupportBase64: supportBase64,
322343
Unpublished: unpublished,
344+
GoType: goType,
323345
}
324346
}
325347

@@ -384,7 +406,172 @@ func extractOpenAPIHeaders(pathItem PathItem) map[string]Parameter {
384406
return headers
385407
}
386408

387-
// Helper test to print all routes and their headers (for debugging)
409+
// validApiPermissionScopes is the set of recognised security scope strings in OpenAPI.
410+
// "FileRequest" is a special scope used by the public upload-request endpoints.
411+
// All others map directly to models.ApiPermission values.
412+
var validApiPermissionScopes = []string{
413+
"VIEW",
414+
"UPLOAD",
415+
"DELETE",
416+
"API_MANAGE",
417+
"EDIT",
418+
"REPLACE",
419+
"MANAGE_USERS",
420+
"MANAGE_LOGS",
421+
"MANAGE_FILE_REQUESTS",
422+
"DOWNLOAD",
423+
"SPECIFIC_GUEST_API_KEY",
424+
}
425+
426+
// apiPermToScope maps each models.ApiPermission value to its expected OpenAPI security scope string.
427+
var apiPermToScope = map[models.ApiPermission]string{
428+
models.ApiPermView: "VIEW",
429+
models.ApiPermUpload: "UPLOAD",
430+
models.ApiPermDelete: "DELETE",
431+
models.ApiPermApiMod: "API_MANAGE",
432+
models.ApiPermEdit: "EDIT",
433+
models.ApiPermReplace: "REPLACE",
434+
models.ApiPermManageUsers: "MANAGE_USERS",
435+
models.ApiPermManageLogs: "MANAGE_LOGS",
436+
models.ApiPermManageFileRequests: "MANAGE_FILE_REQUESTS",
437+
models.ApiPermDownload: "DOWNLOAD",
438+
}
439+
440+
// validateSecurityScopes checks two things for every non-e2e route:
441+
// 1. The OpenAPI security block contains the scope that matches the route's ApiPerm.
442+
// 2. Every scope string present in the OpenAPI spec is a recognised value.
443+
//
444+
// This catches wrong scope strings (e.g. "PERM_GUEST_UPLOAD") and routes whose
445+
// documented permission does not match the permission enforced in Go code.
446+
func validateSecurityScopes(spec *OpenAPISpec) []string {
447+
var failures []string
448+
449+
for _, route := range routes {
450+
if strings.HasPrefix(route.Url, "/e2e/") {
451+
continue
452+
}
453+
454+
openAPIPath := findOpenAPIPath(spec, route)
455+
if openAPIPath == "" {
456+
// Already reported by validateAllRoutesExist
457+
continue
458+
}
459+
460+
pathItem := spec.Paths[openAPIPath]
461+
operations := map[string]*Operation{
462+
"get": pathItem.Get,
463+
"post": pathItem.Post,
464+
"put": pathItem.Put,
465+
"delete": pathItem.Delete,
466+
"patch": pathItem.Patch,
467+
}
468+
469+
for method, op := range operations {
470+
if op == nil {
471+
continue
472+
}
473+
474+
// Validate that every scope present is a known value
475+
for _, secReq := range op.Security {
476+
for _, scopes := range secReq {
477+
for _, scope := range scopes {
478+
if !slices.Contains(validApiPermissionScopes, scope) {
479+
failures = append(failures, fmt.Sprintf(
480+
"Path %s %s: unknown security scope %q (not a valid API permission)",
481+
openAPIPath, method, scope))
482+
}
483+
}
484+
}
485+
}
486+
487+
// Determine the expected scope for this route from the Go ApiPerm value
488+
var expectedScope string
489+
switch {
490+
case route.IsFileRequestApi:
491+
// File-request endpoints use a dedicated API key type
492+
expectedScope = "SPECIFIC_GUEST_API_KEY"
493+
case route.ApiPerm == models.ApiPermNone:
494+
// No auth required — there should be no security block
495+
if len(op.Security) > 0 {
496+
failures = append(failures, fmt.Sprintf(
497+
"Route %s %s: expected no security block (ApiPermNone) but OpenAPI defines one",
498+
openAPIPath, method))
499+
}
500+
continue
501+
default:
502+
scope, ok := apiPermToScope[route.ApiPerm]
503+
if !ok {
504+
failures = append(failures, fmt.Sprintf(
505+
"Route %s %s: no scope mapping defined for ApiPerm value %d",
506+
openAPIPath, method, route.ApiPerm))
507+
continue
508+
}
509+
expectedScope = scope
510+
}
511+
512+
// Check that the expected scope appears in the security block
513+
found := false
514+
for _, secReq := range op.Security {
515+
for _, scopes := range secReq {
516+
if slices.Contains(scopes, expectedScope) {
517+
found = true
518+
}
519+
}
520+
}
521+
if !found {
522+
failures = append(failures, fmt.Sprintf(
523+
"Route %s %s: expected security scope %q (from Go ApiPerm) not found in OpenAPI security block",
524+
openAPIPath, method, expectedScope))
525+
}
526+
}
527+
}
528+
529+
return failures
530+
}
531+
532+
// validateHeaderTypes checks that the OpenAPI schema type for each header parameter
533+
// matches the Go field type from the corresponding RequestParser struct.
534+
// This catches issue 5 (bool field declared as string) and issue 6 (int field declared as string).
535+
func validateHeaderTypes(spec *OpenAPISpec) []string {
536+
var failures []string
537+
538+
for _, route := range routes {
539+
if route.RequestParser == nil {
540+
continue
541+
}
542+
if strings.HasPrefix(route.Url, "/e2e/") {
543+
continue
544+
}
545+
546+
expectedHeaders := extractHeadersFromParser(route.RequestParser)
547+
548+
openAPIPath := findOpenAPIPath(spec, route)
549+
if openAPIPath == "" {
550+
continue
551+
}
552+
553+
openAPIHeaders := extractOpenAPIHeaders(spec.Paths[openAPIPath])
554+
555+
for headerName, info := range expectedHeaders {
556+
if info.Unpublished {
557+
continue
558+
}
559+
openAPIHeader, exists := openAPIHeaders[strings.ToLower(headerName)]
560+
if !exists {
561+
// Already reported by validateRequiredHeaders
562+
continue
563+
}
564+
if openAPIHeader.Schema.Type != info.GoType {
565+
failures = append(failures, fmt.Sprintf(
566+
"Route %s: header %q has Go type %q but OpenAPI schema type is %q",
567+
route.Url, headerName, info.GoType, openAPIHeader.Schema.Type))
568+
}
569+
}
570+
}
571+
572+
return failures
573+
}
574+
388575
func TestPrintRoutesAndHeaders(t *testing.T) {
389576
if testing.Short() {
390577
t.Skip("Skipping debug output in short mode")

0 commit comments

Comments
 (0)