Files
honeyDueAPI/internal/integration/contract_test.go
T
Trey t 81578f6e27
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
feat(auth): replace hand-rolled auth with Ory Kratos — phase 2 backend
Delegates all credential management (login, register, password reset,
email verification, social sign-in) to Ory Kratos. The Go API now acts
as a resource server: the new KratosAuth middleware validates sessions
against the Kratos whoami endpoint, writes the local User mirror into
Echo context, and all existing domain handlers continue working
unchanged. Hand-rolled token auth, AuthToken model, apple_auth/
google_auth services, and the auth refresh flow are removed. Tests are
updated to use the fake-token middleware pattern so existing integration
assertions require no rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:55:56 -05:00

251 lines
7.0 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/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/router"
"github.com/treytartt/honeydue-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
}
// Auth routes delegated to Ory Kratos (phase 2 auth refactor).
// These endpoints are no longer served by the Go API; the spec is retained
// as documentation of the Kratos-facing contract.
kratosRoutes := map[string]bool{
"/auth/login/": true,
"/auth/register/": true,
"/auth/logout/": true,
"/auth/refresh/": true,
"/auth/forgot-password/": true,
"/auth/verify-reset-code/": true,
"/auth/reset-password/": true,
"/auth/verify-email/": true,
"/auth/resend-verification/": true,
"/auth/apple-sign-in/": true,
"/auth/google-sign-in/": true,
}
if kratosRoutes[path] {
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
}