From 32434986e6aed7f58e9f41383e120da25270f6d1 Mon Sep 17 00:00:00 2001 From: chukwudiikeh Date: Sun, 29 Mar 2026 04:49:29 +0000 Subject: [PATCH 1/4] fix(contracts): remove duplicate cancel_market_admin entrypoint (#401) Single canonical cancel_market_admin function remains in lib.rs, delegating to modules::cancellation::cancel_market_admin. Added doc comment to make the canonical entrypoint explicit. ABI is unchanged. --- contracts/predict-iq/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) 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) } From 4f1c617077f130128f49d082901a7f92f98d095f Mon Sep 17 00:00:00 2001 From: chukwudiikeh Date: Sun, 29 Mar 2026 04:49:38 +0000 Subject: [PATCH 2/4] fix(contracts): fix duplicate get_dispute_window and dead constant usage (#402) Single get_dispute_window implementation in modules/resolution.rs using DEFAULT_DISPUTE_WINDOW_SECONDS (72h). Removed undefined DISPUTE_WINDOW_SECONDS reference. Added unit tests covering default value, configured value, and minimum-clamp enforcement. --- .../predict-iq/src/modules/resolution.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 Date: Sun, 29 Mar 2026 04:49:44 +0000 Subject: [PATCH 3/4] feat(api): add structured error codes and consistent error schema (#399) ApiError now includes a machine-readable 'code' field alongside 'message'. Error constructors (internal, bad_request, not_found, conflict, rate_limited) ensure all handlers map errors consistently. OpenAPI ApiError schema updated to require both 'code' and 'message' fields with example values. --- services/api/openapi.yaml | 32 ++++++++++++++++++++++++--- services/api/src/handlers.rs | 43 ++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index 214baef..f33d69b 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -5,8 +5,8 @@ info: description: REST API for the PredictIQ prediction markets platform servers: - - url: http://localhost:3001 - description: Local development + - url: http://localhost:8080 + description: Local development (default bind 0.0.0.0:8080) tags: - name: health @@ -86,6 +86,8 @@ paths: tags: [markets] operationId: resolveMarket summary: Resolve a market and invalidate caches (admin) + security: + - ApiKeyAuth: [] parameters: - $ref: "#/components/parameters/marketId" responses: @@ -95,6 +97,10 @@ paths: application/json: schema: $ref: "#/components/schemas/InvalidationResult" + "401": + $ref: "#/components/responses/ApiError" + "403": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -310,6 +316,8 @@ paths: tags: [email] operationId: emailPreview summary: Preview a rendered email template (admin) + security: + - ApiKeyAuth: [] parameters: - name: template_name in: path @@ -336,6 +344,8 @@ paths: tags: [email] operationId: emailSendTest summary: Send a test email (admin) + security: + - ApiKeyAuth: [] requestBody: required: true content: @@ -357,6 +367,8 @@ paths: tags: [email] operationId: getEmailAnalytics summary: Email analytics (admin) + security: + - ApiKeyAuth: [] parameters: - name: template_name in: query @@ -386,6 +398,8 @@ paths: tags: [email] operationId: getEmailQueueStats summary: Email queue statistics (admin) + security: + - ApiKeyAuth: [] responses: "200": description: Queue stats @@ -449,10 +463,15 @@ components: ApiError: type: object - required: [message] + required: [code, message] properties: + code: + type: string + description: Machine-readable error code + example: INTERNAL_ERROR message: type: string + description: Human-readable error description FeaturedMarketView: type: object @@ -554,3 +573,10 @@ components: application/json: schema: $ref: "#/components/schemas/NewsletterResponse" + + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: Admin API key required for protected endpoints diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index 76fbb51..20c9485 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -18,9 +18,47 @@ use crate::{cache::keys, email::webhook::sendgrid_webhook_handler, AppState}; #[derive(Debug, Serialize)] pub struct ApiError { + pub code: &'static str, pub message: String, } +impl ApiError { + pub fn internal(err: anyhow::Error) -> 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()) From 4505c016d5c6f3ad043ffbc6373f6e2baa480355 Mon Sep 17 00:00:00 2001 From: chukwudiikeh Date: Sun, 29 Mar 2026 04:50:02 +0000 Subject: [PATCH 4/4] fix(api): sync OpenAPI with runtime config and add auth security schemes (#400) - Server URL updated from localhost:3001 to localhost:8080 to match default API_BIND_ADDR (0.0.0.0:8080) in config.rs - ApiKeyAuth security scheme declared (X-API-Key header) - Security applied to all admin routes: resolveMarket, emailPreview, emailSendTest, getEmailAnalytics, getEmailQueueStats - Contract tests added (openapi_contract_test.rs) validating spec-vs-runtime route parity, server URL, security scheme declaration, and ApiError schema --- services/api/tests/openapi_contract_test.rs | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 services/api/tests/openapi_contract_test.rs 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" + ); + } +}