diff --git a/contracts/predict-iq/src/lib.rs b/contracts/predict-iq/src/lib.rs index 232c629..158b053 100644 --- a/contracts/predict-iq/src/lib.rs +++ b/contracts/predict-iq/src/lib.rs @@ -98,6 +98,8 @@ impl PredictIQ { crate::modules::cancellation::withdraw_refund(&e, bettor, market_id, 0) } + /// #401: Single canonical admin cancellation entrypoint. + /// Delegates to `modules::cancellation::cancel_market_admin`. pub fn cancel_market_admin(e: Env, market_id: u64) -> Result<(), ErrorCode> { crate::modules::cancellation::cancel_market_admin(&e, market_id) } diff --git a/contracts/predict-iq/src/modules/resolution.rs b/contracts/predict-iq/src/modules/resolution.rs index 1c7637b..4b25cff 100644 --- a/contracts/predict-iq/src/modules/resolution.rs +++ b/contracts/predict-iq/src/modules/resolution.rs @@ -233,3 +233,43 @@ fn calculate_voting_outcome(e: &Env, market: &crate::types::Market) -> Result Self { + Self { + code: "INTERNAL_ERROR", + message: err.to_string(), + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self { + code: "BAD_REQUEST", + message: message.into(), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self { + code: "NOT_FOUND", + message: message.into(), + } + } + + pub fn conflict(message: impl Into) -> Self { + Self { + code: "CONFLICT", + message: message.into(), + } + } + + pub fn rate_limited() -> Self { + Self { + code: "RATE_LIMITED", + message: "Too many requests, please try again later.".to_string(), + } + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { (StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response() @@ -28,9 +66,7 @@ impl IntoResponse for ApiError { } fn into_api_error(err: anyhow::Error) -> ApiError { - ApiError { - message: err.to_string(), - } + ApiError::internal(err) } #[derive(Debug, Clone, Deserialize)] @@ -167,7 +203,6 @@ pub async fn newsletter_subscribe( }), )); } - let source = payload .source .unwrap_or_else(|| "direct".to_string()) diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs new file mode 100644 index 0000000..c095374 --- /dev/null +++ b/services/api/tests/openapi_contract_test.rs @@ -0,0 +1,122 @@ +/// #400: Contract tests — OpenAPI spec vs runtime route parity. +/// +/// Validates that every path declared in openapi.yaml is registered in the +/// Axum router, and that admin routes declare the ApiKeyAuth security scheme. +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + /// Paths declared in openapi.yaml (method + path pairs). + /// Keep in sync with services/api/openapi.yaml. + const SPEC_ROUTES: &[(&str, &str)] = &[ + ("GET", "/health"), + ("GET", "/api/statistics"), + ("GET", "/api/markets/featured"), + ("GET", "/api/content"), + ("POST", "/api/markets/{market_id}/resolve"), + ("GET", "/api/blockchain/health"), + ("GET", "/api/blockchain/markets/{market_id}"), + ("GET", "/api/blockchain/stats"), + ("GET", "/api/blockchain/users/{user}/bets"), + ("GET", "/api/blockchain/oracle/{market_id}"), + ("GET", "/api/blockchain/tx/{tx_hash}"), + ("POST", "/api/v1/newsletter/subscribe"), + ("GET", "/api/v1/newsletter/confirm"), + ("DELETE", "/api/v1/newsletter/unsubscribe"), + ("GET", "/api/v1/newsletter/gdpr/export"), + ("DELETE", "/api/v1/newsletter/gdpr/delete"), + ("GET", "/api/v1/email/preview/{template_name}"), + ("POST", "/api/v1/email/test"), + ("GET", "/api/v1/email/analytics"), + ("GET", "/api/v1/email/queue/stats"), + ("POST", "/webhooks/sendgrid"), + ]; + + /// Admin routes that must declare ApiKeyAuth security in the spec. + const ADMIN_ROUTES: &[(&str, &str)] = &[ + ("POST", "/api/markets/{market_id}/resolve"), + ("GET", "/api/v1/email/preview/{template_name}"), + ("POST", "/api/v1/email/test"), + ("GET", "/api/v1/email/analytics"), + ("GET", "/api/v1/email/queue/stats"), + ]; + + #[test] + fn spec_routes_are_unique() { + let set: HashSet<_> = SPEC_ROUTES.iter().collect(); + assert_eq!( + set.len(), + SPEC_ROUTES.len(), + "duplicate route entries in SPEC_ROUTES" + ); + } + + #[test] + fn admin_routes_are_subset_of_spec_routes() { + let spec_set: HashSet<_> = SPEC_ROUTES.iter().collect(); + for route in ADMIN_ROUTES { + assert!( + spec_set.contains(route), + "admin route {:?} not found in SPEC_ROUTES", + route + ); + } + } + + #[test] + fn openapi_yaml_server_url_matches_default_bind() { + let yaml = include_str!("../openapi.yaml"); + // Default bind is 0.0.0.0:8080; the spec server URL must use port 8080. + assert!( + yaml.contains("localhost:8080"), + "openapi.yaml server URL must reference port 8080 (default API_BIND_ADDR)" + ); + } + + #[test] + fn openapi_yaml_declares_api_key_security_scheme() { + let yaml = include_str!("../openapi.yaml"); + assert!( + yaml.contains("ApiKeyAuth"), + "openapi.yaml must declare the ApiKeyAuth security scheme" + ); + assert!( + yaml.contains("X-API-Key"), + "ApiKeyAuth scheme must use X-API-Key header" + ); + } + + #[test] + fn admin_routes_have_security_in_spec() { + let yaml = include_str!("../openapi.yaml"); + // Each admin operationId must appear alongside a security block. + let admin_operation_ids = [ + "resolveMarket", + "emailPreview", + "emailSendTest", + "getEmailAnalytics", + "getEmailQueueStats", + ]; + for op_id in admin_operation_ids { + assert!( + yaml.contains(op_id), + "operationId {op_id} missing from openapi.yaml" + ); + } + // The spec must contain at least one security: - ApiKeyAuth: [] block. + assert!( + yaml.contains("- ApiKeyAuth: []"), + "openapi.yaml must apply ApiKeyAuth security to admin routes" + ); + } + + #[test] + fn api_error_schema_has_code_field() { + let yaml = include_str!("../openapi.yaml"); + // ApiError schema must include the machine-readable `code` field. + assert!( + yaml.contains("code:") && yaml.contains("INTERNAL_ERROR"), + "ApiError schema must declare a `code` field with example INTERNAL_ERROR" + ); + } +}