Close all 25 codex audit findings and add KMP contract tests
Remediate all P0-S priority findings from cross-platform architecture audit: - Add input validation and authorization checks across handlers - Harden social auth (Apple/Google) token validation - Add document ownership verification and file type validation - Add rate limiting config and CORS origin restrictions - Add subscription tier enforcement in handlers - Add OpenAPI 3.0.3 spec (81 schemas, 104 operations) - Add URL-level contract test (KMP API routes match spec paths) - Add model-level contract test (65 schemas, 464 fields validated) - Add CI workflow for backend tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
229
internal/integration/contract_test.go
Normal file
229
internal/integration/contract_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/router"
|
||||
"github.com/treytartt/casera-api/internal/testutil"
|
||||
)
|
||||
|
||||
// routeKey is a comparable type for route matching: method + path
|
||||
type routeKey struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
// TestRouteSpecContract verifies that registered Echo routes match the OpenAPI spec.
|
||||
// It ensures bidirectional consistency:
|
||||
// - Every spec path has a corresponding registered route
|
||||
// - Every registered API route has a corresponding spec path
|
||||
func TestRouteSpecContract(t *testing.T) {
|
||||
// --- Parse OpenAPI spec ---
|
||||
specRoutes := extractSpecRoutes(t)
|
||||
require.NotEmpty(t, specRoutes, "OpenAPI spec should have at least one route")
|
||||
|
||||
// --- Set up Echo router ---
|
||||
db := testutil.SetupTestDB(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.Debug = true
|
||||
deps := &router.Dependencies{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
}
|
||||
e := router.SetupRouter(deps)
|
||||
|
||||
echoRoutes := extractEchoRoutes(e.Routes())
|
||||
require.NotEmpty(t, echoRoutes, "Echo router should have at least one route")
|
||||
|
||||
// --- Bidirectional match ---
|
||||
t.Run("spec routes exist in router", func(t *testing.T) {
|
||||
var missing []string
|
||||
for _, sr := range specRoutes {
|
||||
if shouldSkipSpecRoute(sr.Path) {
|
||||
continue
|
||||
}
|
||||
if !containsRoute(echoRoutes, sr) {
|
||||
missing = append(missing, fmt.Sprintf("%s %s", sr.Method, sr.Path))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("OpenAPI spec defines routes not registered in Echo router:\n %s",
|
||||
strings.Join(missing, "\n "))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("router routes exist in spec", func(t *testing.T) {
|
||||
var missing []string
|
||||
for _, er := range echoRoutes {
|
||||
if shouldSkipRouterRoute(er.Path) {
|
||||
continue
|
||||
}
|
||||
if !containsRoute(specRoutes, er) {
|
||||
missing = append(missing, fmt.Sprintf("%s %s", er.Method, er.Path))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("Echo routes not documented in OpenAPI spec:\n %s",
|
||||
strings.Join(missing, "\n "))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// extractSpecRoutes parses the OpenAPI YAML and returns normalized route keys.
|
||||
// Spec paths use OpenAPI param format: /documents/{id}/
|
||||
// These are returned as-is since Echo routes are converted to this format.
|
||||
func extractSpecRoutes(t *testing.T) []routeKey {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile("../../docs/openapi.yaml")
|
||||
require.NoError(t, err, "Failed to read openapi.yaml")
|
||||
|
||||
var spec struct {
|
||||
Paths map[string]map[string]interface{} `yaml:"paths"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &spec), "Failed to parse openapi.yaml")
|
||||
|
||||
var routes []routeKey
|
||||
for path, methods := range spec.Paths {
|
||||
for method := range methods {
|
||||
upper := strings.ToUpper(method)
|
||||
// Skip non-HTTP methods (parameters, summary, etc.)
|
||||
switch upper {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
|
||||
routes = append(routes, routeKey{Method: upper, Path: path})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
return routes
|
||||
}
|
||||
|
||||
// extractEchoRoutes returns normalized route keys from Echo's registered routes.
|
||||
// Filters out admin, static, health, tracking, and internal routes.
|
||||
func extractEchoRoutes(echoRoutes []*echo.Route) []routeKey {
|
||||
seen := make(map[routeKey]bool)
|
||||
var routes []routeKey
|
||||
|
||||
for _, r := range echoRoutes {
|
||||
if shouldSkipRoute(r.Path, r.Method) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip /api prefix to match spec paths (spec server base is /api)
|
||||
path := strings.TrimPrefix(r.Path, "/api")
|
||||
|
||||
// Normalize Echo :param to OpenAPI {param}
|
||||
path = normalizePathToOpenAPI(path)
|
||||
|
||||
key := routeKey{Method: r.Method, Path: path}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
routes = append(routes, key)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
return routes
|
||||
}
|
||||
|
||||
// normalizePathToOpenAPI converts Echo `:param` to OpenAPI `{param}` format.
|
||||
func normalizePathToOpenAPI(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, ":") {
|
||||
parts[i] = "{" + strings.TrimPrefix(part, ":") + "}"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// shouldSkipRoute returns true for routes that are not part of the public API spec.
|
||||
func shouldSkipRoute(path, method string) bool {
|
||||
// Skip non-API routes (static files, root page)
|
||||
if !strings.HasPrefix(path, "/api/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip admin routes
|
||||
if strings.HasPrefix(path, "/api/admin") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip health check (internal, not in spec)
|
||||
if path == "/api/health/" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip email tracking (internal, not in spec)
|
||||
if strings.HasPrefix(path, "/api/track/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip echo-internal routes (e.g., OPTIONS auto-generated by CORS)
|
||||
if method == "echo_route_not_found" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldSkipSpecRoute returns true for spec routes that require optional services
|
||||
// (e.g., storage/media routes require a non-nil StorageService which is not available in tests).
|
||||
func shouldSkipSpecRoute(path string) bool {
|
||||
// Upload and media routes are conditionally registered (require StorageService)
|
||||
if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldSkipRouterRoute returns true for router routes that are intentionally
|
||||
// not documented in the OpenAPI spec (internal aliases, webhooks, etc.).
|
||||
func shouldSkipRouterRoute(path string) bool {
|
||||
skipPaths := map[string]bool{
|
||||
// Internal auth alias for mobile client compatibility
|
||||
"/auth/verify/": true,
|
||||
// Static data cache management (internal)
|
||||
"/static_data/refresh/": true,
|
||||
// Server-to-server webhook routes (called by Apple/Google, not mobile clients)
|
||||
"/subscription/webhook/apple/": true,
|
||||
"/subscription/webhook/google/": true,
|
||||
// User management routes (internal/admin-facing, not in mobile API spec)
|
||||
"/users/": true,
|
||||
"/users/profiles/": true,
|
||||
}
|
||||
if skipPaths[path] {
|
||||
return true
|
||||
}
|
||||
// Skip /users/{id}/ pattern
|
||||
if strings.HasPrefix(path, "/users/{") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsRoute checks if a routeKey exists in a slice.
|
||||
func containsRoute(routes []routeKey, target routeKey) bool {
|
||||
for _, r := range routes {
|
||||
if r.Method == target.Method && r.Path == target.Path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user