@@ -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
2528type Operation struct {
26- Parameters []Parameter `json:"parameters,omitempty"`
29+ Parameters []Parameter `json:"parameters,omitempty"`
30+ Security []map [string ][]string `json:"security,omitempty"`
2731}
2832
2933type 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+
388575func TestPrintRoutesAndHeaders (t * testing.T ) {
389576 if testing .Short () {
390577 t .Skip ("Skipping debug output in short mode" )
0 commit comments