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:
286
internal/integration/kmp_contract_test.go
Normal file
286
internal/integration/kmp_contract_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestKMPSpecContract verifies that KMP API clients (*Api.kt) match the OpenAPI spec.
|
||||
// It ensures bidirectional consistency:
|
||||
// - Every spec endpoint (minus exclusions) has a KMP implementation
|
||||
// - Every KMP endpoint (after alias resolution) exists in the spec
|
||||
func TestKMPSpecContract(t *testing.T) {
|
||||
// --- Parse OpenAPI spec ---
|
||||
specRoutes := extractSpecRoutesForKMP(t)
|
||||
require.NotEmpty(t, specRoutes, "OpenAPI spec should have at least one route")
|
||||
|
||||
// --- Extract KMP routes ---
|
||||
kmpRoutes := extractKMPRoutes(t)
|
||||
require.NotEmpty(t, kmpRoutes, "KMP API clients should have at least one route")
|
||||
|
||||
t.Logf("Spec routes: %d, KMP routes: %d", len(specRoutes), len(kmpRoutes))
|
||||
|
||||
// --- Direction 1: Every spec endpoint covered by KMP ---
|
||||
t.Run("spec endpoints covered by KMP", func(t *testing.T) {
|
||||
var missing []string
|
||||
for _, sr := range specRoutes {
|
||||
if specEndpointsKMPSkips[sr] {
|
||||
continue
|
||||
}
|
||||
if !containsRoute(kmpRoutes, sr) {
|
||||
missing = append(missing, fmt.Sprintf("%s %s", sr.Method, sr.Path))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("OpenAPI spec defines endpoints not implemented in KMP:\n %s",
|
||||
strings.Join(missing, "\n "))
|
||||
}
|
||||
})
|
||||
|
||||
// --- Direction 2: Every KMP endpoint exists in spec ---
|
||||
t.Run("KMP endpoints exist in spec", func(t *testing.T) {
|
||||
var missing []string
|
||||
for _, kr := range kmpRoutes {
|
||||
if !containsRoute(specRoutes, kr) {
|
||||
missing = append(missing, fmt.Sprintf("%s %s", kr.Method, kr.Path))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("KMP API clients call endpoints not defined in OpenAPI spec:\n %s",
|
||||
strings.Join(missing, "\n "))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// specEndpointsKMPSkips are spec endpoints that KMP intentionally does not implement.
|
||||
// All paths must use normalized form ({_} for all path params) to match extractSpecRoutesForKMP output.
|
||||
var specEndpointsKMPSkips = map[routeKey]bool{
|
||||
// Upload endpoints — KMP embeds file uploads in domain-specific multipart calls
|
||||
{Method: "POST", Path: "/uploads/image/"}: true,
|
||||
{Method: "POST", Path: "/uploads/document/"}: true,
|
||||
{Method: "POST", Path: "/uploads/completion-file/"}: true,
|
||||
{Method: "POST", Path: "/uploads/completion/"}: true,
|
||||
{Method: "DELETE", Path: "/uploads/"}: true,
|
||||
// Media proxy — KMP uses dynamic URLs from API responses
|
||||
{Method: "GET", Path: "/media/{_}"}: true, // /media/{path}
|
||||
{Method: "GET", Path: "/media/document/{_}"}: true,
|
||||
{Method: "GET", Path: "/media/document-image/{_}"}: true,
|
||||
{Method: "GET", Path: "/media/completion-image/{_}"}: true,
|
||||
// PUT/PATCH variants where KMP uses only one method
|
||||
{Method: "PUT", Path: "/contractors/{_}/"}: true, // KMP uses PATCH
|
||||
{Method: "PUT", Path: "/documents/{_}/"}: true, // KMP uses PATCH
|
||||
{Method: "PATCH", Path: "/residences/{_}/"}: true, // KMP uses PUT
|
||||
{Method: "PATCH", Path: "/tasks/{_}/completions/"}: true, // KMP uses PUT on /task-completions/{_}/
|
||||
{Method: "PATCH", Path: "/auth/profile/"}: true, // KMP uses PUT
|
||||
// Task action endpoints where KMP uses PATCH on main resource or different endpoint
|
||||
{Method: "POST", Path: "/tasks/{_}/mark-in-progress/"}: true, // KMP uses PATCH tasks/{_}/
|
||||
{Method: "POST", Path: "/tasks/{_}/quick-complete/"}: true, // KMP uses POST /task-completions/
|
||||
// Subscription endpoints handled client-side or via different path
|
||||
{Method: "POST", Path: "/subscription/cancel/"}: true, // Handled by App Store / Play Store
|
||||
{Method: "GET", Path: "/subscription/"}: true, // KMP uses /subscription/status/
|
||||
{Method: "GET", Path: "/subscription/upgrade-trigger/{_}/"}: true, // KMP uses list endpoint
|
||||
// Auth endpoints not yet implemented in KMP
|
||||
{Method: "POST", Path: "/auth/resend-verification/"}: true,
|
||||
// Document warranty endpoint — KMP filters via query params on /documents/
|
||||
{Method: "GET", Path: "/documents/warranties/"}: true,
|
||||
// Device management — KMP uses different endpoints
|
||||
{Method: "GET", Path: "/notifications/devices/"}: true, // KMP doesn't list devices
|
||||
{Method: "POST", Path: "/notifications/devices/"}: true, // KMP uses /notifications/devices/register/
|
||||
{Method: "POST", Path: "/notifications/devices/unregister/"}: true, // KMP uses DELETE on device ID
|
||||
{Method: "PATCH", Path: "/notifications/preferences/"}: true, // KMP uses PUT
|
||||
}
|
||||
|
||||
// kmpRouteAliases maps KMP paths to their canonical spec paths.
|
||||
// Applied after extraction but before spec comparison.
|
||||
// Currently empty — all KMP paths match spec paths after the /auth/verify/ → /auth/verify-email/ fix.
|
||||
var kmpRouteAliases = map[routeKey]routeKey{}
|
||||
|
||||
// kmpDynamicExpansions maps dynamic route patterns to concrete actions.
|
||||
// Used for TaskApi.postTaskAction which builds paths like /tasks/$id/$action/
|
||||
var kmpDynamicExpansions = map[routeKey][]string{
|
||||
{Method: "POST", Path: "/tasks/{_}/{_}/"}: {"cancel", "uncancel", "archive", "unarchive"},
|
||||
}
|
||||
|
||||
// Regex patterns for extracting HTTP calls from KMP *Api.kt files.
|
||||
var (
|
||||
// Standard Ktor calls: client.get("$baseUrl/path/")
|
||||
reStandardCall = regexp.MustCompile(`client\.(get|post|put|patch|delete)\(\s*"?\$baseUrl(/[^")\s]+)"?`)
|
||||
// Multipart calls: submitFormWithBinaryData(url = "$baseUrl/path/", ...)
|
||||
reMultipartCall = regexp.MustCompile(`submitFormWithBinaryData\(\s*(?:\n\s*)?url\s*=\s*"\$baseUrl(/[^"]+)"`)
|
||||
)
|
||||
|
||||
// extractKMPRoutes scans KMP *Api.kt files and returns normalized route keys.
|
||||
func extractKMPRoutes(t *testing.T) []routeKey {
|
||||
t.Helper()
|
||||
|
||||
// KMP source directory relative to this test file
|
||||
kmpDir := filepath.Join("..", "..", "..", "MyCribKMM", "composeApp", "src", "commonMain", "kotlin", "com", "example", "casera", "network")
|
||||
|
||||
// Verify directory exists — skip in CI where KMP repo may not be checked out
|
||||
info, err := os.Stat(kmpDir)
|
||||
if os.IsNotExist(err) {
|
||||
t.Skipf("KMP network directory not found at %s (expected in monorepo layout)", kmpDir)
|
||||
}
|
||||
require.NoError(t, err, "Failed to stat KMP network directory at %s", kmpDir)
|
||||
require.True(t, info.IsDir(), "KMP network path is not a directory")
|
||||
|
||||
// Find all *Api.kt files
|
||||
matches, err := filepath.Glob(filepath.Join(kmpDir, "*Api.kt"))
|
||||
require.NoError(t, err, "Failed to glob *Api.kt files")
|
||||
require.NotEmpty(t, matches, "No *Api.kt files found in %s", kmpDir)
|
||||
|
||||
seen := make(map[routeKey]bool)
|
||||
var routes []routeKey
|
||||
|
||||
for _, file := range matches {
|
||||
data, err := os.ReadFile(file)
|
||||
require.NoError(t, err, "Failed to read %s", file)
|
||||
content := string(data)
|
||||
|
||||
// Extract standard HTTP calls
|
||||
for _, match := range reStandardCall.FindAllStringSubmatch(content, -1) {
|
||||
method := strings.ToUpper(match[1])
|
||||
path := normalizeKMPPath(match[2])
|
||||
key := routeKey{Method: method, Path: path}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
routes = append(routes, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract multipart calls (these are always POST)
|
||||
for _, match := range reMultipartCall.FindAllStringSubmatch(content, -1) {
|
||||
path := normalizeKMPPath(match[1])
|
||||
key := routeKey{Method: "POST", Path: path}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
routes = append(routes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand dynamic routes (e.g., /tasks/$id/$action/ → cancel, uncancel, etc.)
|
||||
routes = expandDynamicRoutes(routes, seen)
|
||||
|
||||
// Resolve aliases (e.g., /auth/verify/ → /auth/verify-email/)
|
||||
routes = resolveAliases(routes)
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Path == routes[j].Path {
|
||||
return routes[i].Method < routes[j].Method
|
||||
}
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
return routes
|
||||
}
|
||||
|
||||
// normalizeKMPPath converts KMP string interpolation paths to a generic param format.
|
||||
// Replaces $variable segments with {_} to match normalized spec paths.
|
||||
// Example: /tasks/$id/$action/ → /tasks/{_}/{_}/
|
||||
func normalizeKMPPath(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, "$") {
|
||||
parts[i] = "{_}"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// normalizeSpecPath converts OpenAPI {paramName} segments to {_} for comparison.
|
||||
// Example: /tasks/{id}/completions/ → /tasks/{_}/completions/
|
||||
// Special case: /media/{path} stays as /media/{path} (wildcard, not a segment param)
|
||||
func normalizeSpecPath(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
|
||||
parts[i] = "{_}"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// expandDynamicRoutes expands routes with multiple path params into concrete action routes.
|
||||
// For example, POST /tasks/{_}/{_}/ becomes POST /tasks/{_}/cancel/, etc.
|
||||
func expandDynamicRoutes(routes []routeKey, seen map[routeKey]bool) []routeKey {
|
||||
var expanded []routeKey
|
||||
for _, r := range routes {
|
||||
if actions, ok := kmpDynamicExpansions[r]; ok {
|
||||
// Replace the last {_} segment with each concrete action
|
||||
for _, action := range actions {
|
||||
// Build path: replace last {_} with action name
|
||||
path := strings.TrimSuffix(r.Path, "{_}/") + action + "/"
|
||||
key := routeKey{Method: r.Method, Path: path}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
expanded = append(expanded, key)
|
||||
}
|
||||
}
|
||||
// Don't include the generic pattern itself
|
||||
continue
|
||||
}
|
||||
expanded = append(expanded, r)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
// resolveAliases replaces KMP routes with their canonical spec equivalents.
|
||||
func resolveAliases(routes []routeKey) []routeKey {
|
||||
result := make([]routeKey, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if canonical, ok := kmpRouteAliases[r]; ok {
|
||||
result = append(result, canonical)
|
||||
} else {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractSpecRoutesForKMP parses openapi.yaml and returns routes normalized for KMP comparison.
|
||||
func extractSpecRoutesForKMP(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")
|
||||
|
||||
seen := make(map[routeKey]bool)
|
||||
var routes []routeKey
|
||||
for path, methods := range spec.Paths {
|
||||
for method := range methods {
|
||||
upper := strings.ToUpper(method)
|
||||
switch upper {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
|
||||
normalized := normalizeSpecPath(path)
|
||||
key := routeKey{Method: upper, Path: normalized}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
routes = append(routes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Path == routes[j].Path {
|
||||
return routes[i].Method < routes[j].Method
|
||||
}
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
return routes
|
||||
}
|
||||
Reference in New Issue
Block a user