Files
honeyDueAPI/internal/integration/contract_test.go
Trey t bb7493f033 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>
2026-02-18 13:15:07 -06:00

230 lines
6.3 KiB
Go

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
}