diff --git a/README.md b/README.md index 1102975e8..8e67a7a29 100644 --- a/README.md +++ b/README.md @@ -278,12 +278,32 @@ cargo test properties cargo test integration ``` + > :warning: Debian/Ubuntu: If you encounter OpenSSL build errors, install the required packages: ```bash sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev ``` +#### Run tests against Redis + +1. You can start a Redis instance using the following command: + +```bash +docker run -d \ + --name redis \ + -p 6379:6379 \ + redis:latest +``` + +2. Then remove the `#[ignore = "Requires active Redis instance"]` attribute from the tests you want to run. + +3. Run the tests using single thread to avoid race conditions within suites: + +```bash +cargo test your_test_regex -- --test-threads=1 + + ### Config files Create `config/config.json` file. You can use `config/config.example.json` as a starting point: diff --git a/src/api/controllers/api_key.rs b/src/api/controllers/api_key.rs new file mode 100644 index 000000000..4ad1553f3 --- /dev/null +++ b/src/api/controllers/api_key.rs @@ -0,0 +1,273 @@ +//! # Api Key Controller +//! +//! Handles HTTP endpoints for api key operations including: +//! - Create api keys +//! - List api keys +//! - Delete api keys +use crate::{ + jobs::JobProducerTrait, + models::{ + ApiError, ApiKeyRepoModel, ApiKeyRequest, ApiKeyResponse, ApiResponse, NetworkRepoModel, + NotificationRepoModel, PaginationMeta, PaginationQuery, RelayerRepoModel, SignerRepoModel, + ThinDataAppState, TransactionRepoModel, + }, + repositories::{ + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, + }, +}; +use actix_web::HttpResponse; +use eyre::Result; + +/// Create api key +/// +/// # Arguments +/// +/// * `api_key_request` - The api key request. +/// * `name` - The name of the api key. +/// * `allowed_origins` - The allowed origins for the api key. +/// * `permissions` - The permissions for the api key. +/// * `state` - The application state containing the api key repository. +/// +/// # Returns +/// +/// The result of the plugin call. +pub async fn create_api_key( + api_key_request: ApiKeyRequest, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let api_key = ApiKeyRepoModel::try_from(api_key_request)?; + + let api_key = state.api_key_repository.create(api_key).await?; + + Ok(HttpResponse::Created().json(ApiResponse::success(api_key))) +} + +/// List api keys +/// +/// # Arguments +/// +/// * `query` - The pagination query parameters. +/// * `page` - The page number. +/// * `per_page` - The number of items per page. +/// * `state` - The application state containing the api key repository. +/// +/// # Returns +/// +/// The result of the api key list. +pub async fn list_api_keys( + query: PaginationQuery, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let api_keys = state.api_key_repository.list_paginated(query).await?; + + let api_key_items: Vec = api_keys.items.into_iter().collect(); + + // Subtract the "value" from the api key to avoid exposing it. + let api_key_items: Vec = api_key_items + .into_iter() + .map(ApiKeyResponse::try_from) + .collect::, ApiError>>()?; + + Ok(HttpResponse::Ok().json(ApiResponse::paginated( + api_key_items, + PaginationMeta { + total_items: api_keys.total, + current_page: api_keys.page, + per_page: api_keys.per_page, + }, + ))) +} + +/// Get api key permissions +/// +/// # Arguments +/// +/// * `api_key_id` - The id of the api key. +/// * `state` - The application state containing the api key repository. +/// +pub async fn get_api_key_permissions( + api_key_id: String, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let permissions = state + .api_key_repository + .list_permissions(&api_key_id) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::success(permissions))) +} + +/// Delete api key +/// +/// # Arguments +/// +/// * `api_key_id` - The id of the api key. +/// * `state` - The application state containing the api key repository. +/// +pub async fn delete_api_key( + _api_key_id: String, + _state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + // state.api_key_repository.delete_by_id(&api_key_id).await?; + + // Ok(HttpResponse::Ok().json(ApiResponse::success(api_key_id))) + Ok(HttpResponse::Ok().json(ApiResponse::::error("Not implemented".to_string()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{ApiKeyRepoModel, PaginationQuery, SecretString}, + utils::mocks::mockutils::create_mock_app_state, + }; + use actix_web::web::ThinData; + + /// Helper function to create a test api key model + fn create_test_api_key_model(id: &str) -> ApiKeyRepoModel { + ApiKeyRepoModel { + id: id.to_string(), + value: SecretString::new("test-api-key-value"), + name: "Test API Key".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: "2023-01-01T00:00:00Z".to_string(), + } + } + + /// Helper function to create a test api key create request + fn create_test_api_key_create_request(name: &str) -> ApiKeyRequest { + ApiKeyRequest { + name: name.to_string(), + permissions: vec!["relayer:all:execute".to_string()], + allowed_origins: Some(vec!["*".to_string()]), + } + } + + #[actix_web::test] + async fn test_create_api_key() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let api_key_request = create_test_api_key_create_request("Test API Key"); + + let result = create_api_key(api_key_request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + } + + #[actix_web::test] + async fn test_list_api_keys_empty() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_api_keys(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_list_api_keys_with_data() { + let api_key = create_test_api_key_model("test-api-key-1"); + let app_state = + create_mock_app_state(Some(vec![api_key]), None, None, None, None, None).await; + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_api_keys(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_get_api_key_permissions() { + let api_key = create_test_api_key_model("test-api-key-1"); + let api_key_id = api_key.id.clone(); + let app_state = + create_mock_app_state(Some(vec![api_key]), None, None, None, None, None).await; + + let result = get_api_key_permissions(api_key_id, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + // #[actix_web::test] + // async fn test_delete_api_key() { + // let api_key = create_test_api_key_model("test-api-key-1"); + // let api_key_id = api_key.id.clone(); + // let app_state = + // create_mock_app_state(Some(vec![api_key]), None, None, None, None, None).await; + + // let result = delete_api_key(api_key_id, ThinData(app_state)).await; + + // assert!(result.is_ok()); + // let response = result.unwrap(); + // assert_eq!(response.status(), 200); + // } + + #[actix_web::test] + async fn test_get_permissions_nonexistent_api_key() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + + let result = + get_api_key_permissions("nonexistent-id".to_string(), ThinData(app_state)).await; + + assert!(result.is_err()); + } +} diff --git a/src/api/controllers/mod.rs b/src/api/controllers/mod.rs index 91690bc1c..4e5558928 100644 --- a/src/api/controllers/mod.rs +++ b/src/api/controllers/mod.rs @@ -4,11 +4,13 @@ //! //! ## Controllers //! +//! * `api_key` - API key management endpoints //! * `relayer` - Transaction and relayer management endpoints //! * `plugin` - Plugin endpoints //! * `notifications` - Notification management endpoints //! * `signers` - Signer management endpoints +pub mod api_key; pub mod notification; pub mod plugin; pub mod relayer; diff --git a/src/api/controllers/notification.rs b/src/api/controllers/notification.rs index 0c968571b..ad9c0cb4b 100644 --- a/src/api/controllers/notification.rs +++ b/src/api/controllers/notification.rs @@ -15,8 +15,8 @@ use crate::{ PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, }; @@ -33,9 +33,9 @@ use eyre::Result; /// # Returns /// /// A paginated list of notifications. -pub async fn list_notifications( +pub async fn list_notifications( query: PaginationQuery, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -46,6 +46,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let notifications = state.notification_repository.list_paginated(query).await?; @@ -72,9 +73,9 @@ where /// # Returns /// /// The notification details or an error if not found. -pub async fn get_notification( +pub async fn get_notification( notification_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -85,6 +86,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let notification = state .notification_repository @@ -105,9 +107,9 @@ where /// # Returns /// /// The created notification or an error if creation fails. -pub async fn create_notification( +pub async fn create_notification( request: NotificationCreateRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -118,6 +120,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // Convert request to core notification (validates automatically) let notification = Notification::try_from(request)?; @@ -144,10 +147,10 @@ where /// # Returns /// /// The updated notification or an error if update fails. -pub async fn update_notification( +pub async fn update_notification( notification_id: String, request: NotificationUpdateRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -158,6 +161,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // Get the existing notification from repository let existing_repo_model = state @@ -193,9 +197,9 @@ where /// This endpoint ensures that notifications cannot be deleted if they are still being /// used by any relayers. This prevents breaking existing relayer configurations /// and maintains system integrity. -pub async fn delete_notification( +pub async fn delete_notification( notification_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -206,6 +210,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // First check if the notification exists let _notification = state @@ -279,7 +284,7 @@ mod tests { #[actix_web::test] async fn test_list_notifications_empty() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let query = PaginationQuery { page: 1, per_page: 10, @@ -304,7 +309,7 @@ mod tests { #[actix_web::test] async fn test_list_notifications_with_data() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create test notifications let notification1 = create_test_notification_model("test-1"); @@ -350,7 +355,7 @@ mod tests { #[actix_web::test] async fn test_list_notifications_pagination() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create multiple test notifications for i in 1..=5 { @@ -386,7 +391,7 @@ mod tests { #[actix_web::test] async fn test_get_notification_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("test-notification"); @@ -418,7 +423,7 @@ mod tests { #[actix_web::test] async fn test_get_notification_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let result = get_notification("non-existent".to_string(), ThinData(app_state)).await; @@ -429,7 +434,7 @@ mod tests { #[actix_web::test] async fn test_create_notification_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = create_test_notification_create_request("new-notification"); @@ -455,7 +460,7 @@ mod tests { #[actix_web::test] async fn test_create_notification_without_signing_key() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = NotificationCreateRequest { id: Some("new-notification".to_string()), @@ -486,7 +491,7 @@ mod tests { #[actix_web::test] async fn test_update_notification_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("test-notification"); @@ -524,7 +529,7 @@ mod tests { #[actix_web::test] async fn test_update_notification_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let update_request = create_test_notification_update_request(); @@ -542,7 +547,7 @@ mod tests { #[actix_web::test] async fn test_delete_notification_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("test-notification"); @@ -573,7 +578,7 @@ mod tests { #[actix_web::test] async fn test_delete_notification_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let result = delete_notification("non-existent".to_string(), ThinData(app_state)).await; @@ -618,8 +623,8 @@ mod tests { #[actix_web::test] async fn test_create_notification_validates_repository_creation() { - let app_state = create_mock_app_state(None, None, None, None, None).await; - let app_state_2 = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app_state_2 = create_mock_app_state(None, None, None, None, None, None).await; let request = create_test_notification_create_request("new-notification"); let result = create_notification(request, ThinData(app_state)).await; @@ -651,7 +656,7 @@ mod tests { #[actix_web::test] async fn test_create_notification_validation_error() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a request with only invalid ID to make test deterministic let request = NotificationCreateRequest { @@ -676,7 +681,7 @@ mod tests { #[actix_web::test] async fn test_update_notification_validation_error() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("test-notification"); @@ -715,7 +720,7 @@ mod tests { #[actix_web::test] async fn test_delete_notification_blocked_by_connected_relayers() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("connected-notification"); @@ -761,7 +766,7 @@ mod tests { #[actix_web::test] async fn test_delete_notification_after_relayer_removed() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("cleanup-notification"); @@ -795,7 +800,7 @@ mod tests { assert!(result.is_err()); // Create new app state for second test (since app_state was consumed) - let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let app_state2 = create_mock_app_state(None, None, None, None, None, None).await; // Re-create the notification in the new state let notification2 = create_test_notification_model("cleanup-notification"); @@ -816,7 +821,7 @@ mod tests { #[actix_web::test] async fn test_delete_notification_with_multiple_relayers() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test notification let notification = create_test_notification_model("multi-relayer-notification"); @@ -903,7 +908,7 @@ mod tests { #[actix_web::test] async fn test_delete_notification_with_some_relayers_using_different_notification() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create two test notifications let notification1 = create_test_notification_model("notification-to-delete"); @@ -988,7 +993,7 @@ mod tests { } // Try to delete the second notification - should succeed (no relayers using it in our test) - let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let app_state2 = create_mock_app_state(None, None, None, None, None, None).await; let notification2_recreated = create_test_notification_model("other-notification"); app_state2 .notification_repository diff --git a/src/api/controllers/plugin.rs b/src/api/controllers/plugin.rs index 542d2575e..766303e0f 100644 --- a/src/api/controllers/plugin.rs +++ b/src/api/controllers/plugin.rs @@ -10,8 +10,8 @@ use crate::{ ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, services::plugins::{PluginCallResponse, PluginRunner, PluginService, PluginServiceTrait}, }; @@ -30,10 +30,10 @@ use std::sync::Arc; /// # Returns /// /// The result of the plugin call. -pub async fn call_plugin( +pub async fn call_plugin( plugin_id: String, plugin_call_request: PluginCallRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -44,6 +44,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let plugin = state .plugin_repository @@ -75,9 +76,9 @@ where /// # Returns /// /// The result of the plugin list. -pub async fn list_plugins( +pub async fn list_plugins( query: PaginationQuery, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -88,6 +89,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let plugins = state.plugin_repository.list_paginated(query).await?; @@ -122,7 +124,8 @@ mod tests { path: "test-path".to_string(), timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), }; - let app_state = create_mock_app_state(None, None, None, Some(vec![plugin]), None).await; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; let plugin_call_request = PluginCallRequest { params: serde_json::json!({"key":"value"}), }; diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 4557579bd..ca4321309 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -26,8 +26,8 @@ use crate::{ TransactionRepoModel, TransactionResponse, TransactionStatus, UpdateRelayerRequestRaw, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, services::{Signer, SignerFactory}, }; @@ -44,9 +44,9 @@ use eyre::Result; /// # Returns /// /// A paginated list of relayers. -pub async fn list_relayers( +pub async fn list_relayers( query: PaginationQuery, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -57,6 +57,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayers = state.relayer_repository.list_paginated(query).await?; @@ -83,9 +84,9 @@ where /// # Returns /// /// The details of the specified relayer. -pub async fn get_relayer( +pub async fn get_relayer( relayer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -96,6 +97,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id, &state).await?; @@ -124,9 +126,9 @@ where /// - **Network Validation**: Confirms the specified network exists for the given network type /// /// All validations must pass before the relayer is created, ensuring referential integrity and security constraints. -pub async fn create_relayer( +pub async fn create_relayer( request: CreateRelayerRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -137,6 +139,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // Convert request to domain relayer (validates automatically) let relayer = RelayerDomainModel::try_from(request)?; @@ -219,10 +222,10 @@ where /// # Returns /// /// The updated relayer information. -pub async fn update_relayer( +pub async fn update_relayer( relayer_id: String, patch: serde_json::Value, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -233,6 +236,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; @@ -290,9 +294,9 @@ where /// /// This endpoint ensures that relayers cannot be deleted if they have any pending /// or active transactions. This prevents data loss and maintains system integrity. -pub async fn delete_relayer( +pub async fn delete_relayer( relayer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -303,6 +307,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // Check if the relayer exists let _relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; @@ -344,9 +349,9 @@ where /// # Returns /// /// The status of the specified relayer. -pub async fn get_relayer_status( +pub async fn get_relayer_status( relayer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -357,6 +362,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_network_relayer(relayer_id, &state).await?; @@ -375,9 +381,9 @@ where /// # Returns /// /// The balance of the specified relayer. -pub async fn get_relayer_balance( +pub async fn get_relayer_balance( relayer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -388,6 +394,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_network_relayer(relayer_id, &state).await?; @@ -440,10 +447,10 @@ pub async fn send_transaction( /// # Returns /// /// The details of the specified transaction. -pub async fn get_transaction_by_id( +pub async fn get_transaction_by_id( relayer_id: String, transaction_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -454,6 +461,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { if relayer_id.is_empty() || transaction_id.is_empty() { return Ok(HttpResponse::Ok().json(ApiResponse::<()>::error( @@ -481,10 +489,10 @@ where /// # Returns /// /// The details of the specified transaction. -pub async fn get_transaction_by_nonce( +pub async fn get_transaction_by_nonce( relayer_id: String, nonce: u64, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -495,6 +503,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; @@ -527,10 +536,10 @@ where /// # Returns /// /// A paginated list of transactions -pub async fn list_transactions( +pub async fn list_transactions( relayer_id: String, query: PaginationQuery, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -541,6 +550,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { get_relayer_by_id(relayer_id.clone(), &state).await?; @@ -572,9 +582,9 @@ where /// # Returns /// /// A success response with details about cancelled and failed transactions. -pub async fn delete_pending_transactions( +pub async fn delete_pending_transactions( relayer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -585,6 +595,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id, &state).await?; relayer.validate_active_state()?; @@ -678,10 +689,10 @@ pub async fn replace_transaction( /// # Returns /// /// The signed data response. -pub async fn sign_data( +pub async fn sign_data( relayer_id: String, request: SignDataRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -692,6 +703,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; @@ -717,10 +729,10 @@ where /// # Returns /// /// The signed typed data response. -pub async fn sign_typed_data( +pub async fn sign_typed_data( relayer_id: String, request: SignTypedDataRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -731,6 +743,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; @@ -752,10 +765,10 @@ where /// # Returns /// /// The result of the JSON-RPC call. -pub async fn relayer_rpc( +pub async fn relayer_rpc( relayer_id: String, request: serde_json::Value, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -766,6 +779,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; @@ -788,10 +802,10 @@ where /// # Returns /// /// The signed transaction response. -pub async fn sign_transaction( +pub async fn sign_transaction( relayer_id: String, request: SignTransactionRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -802,6 +816,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; @@ -923,8 +938,15 @@ mod tests { setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let request = create_test_relayer_create_request( Some("test-relayer".to_string()), @@ -957,8 +979,15 @@ mod tests { setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let mut request = create_test_relayer_create_request( Some("test-relayer-policies".to_string()), @@ -1006,8 +1035,15 @@ mod tests { setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let mut request = create_test_relayer_create_request( Some("test-relayer-partial".to_string()), @@ -1051,8 +1087,15 @@ mod tests { setup_test_env(); let network = create_mock_solana_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let mut request = create_test_relayer_create_request( Some("test-solana-relayer".to_string()), @@ -1118,8 +1161,15 @@ mod tests { setup_test_env(); let network = create_mock_stellar_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let mut request = create_test_relayer_create_request( Some("test-stellar-relayer".to_string()), @@ -1163,8 +1213,15 @@ mod tests { setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let mut request = create_test_relayer_create_request( Some("test-mismatch-relayer".to_string()), @@ -1198,8 +1255,15 @@ mod tests { let network = create_mock_network(); let signer = create_mock_signer(); let notification = create_mock_notification("test-notification".to_string()); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; // Add notification manually since create_mock_app_state doesn't handle notifications app_state @@ -1233,7 +1297,8 @@ mod tests { #[actix_web::test] async fn test_create_relayer_nonexistent_signer() { let network = create_mock_network(); - let app_state = create_mock_app_state(None, None, Some(vec![network]), None, None).await; + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; let request = create_test_relayer_create_request( Some("test-relayer".to_string()), @@ -1256,7 +1321,8 @@ mod tests { #[actix_web::test] async fn test_create_relayer_nonexistent_network() { let signer = create_mock_signer(); - let app_state = create_mock_app_state(None, Some(vec![signer]), None, None, None).await; + let app_state = + create_mock_app_state(None, None, Some(vec![signer]), None, None, None).await; let request = create_test_relayer_create_request( Some("test-relayer".to_string()), @@ -1285,6 +1351,7 @@ mod tests { existing_relayer.signer_id = "test".to_string(); // Match the mock signer id existing_relayer.network = "test".to_string(); // Match the mock network name let app_state = create_mock_app_state( + None, Some(vec![existing_relayer]), Some(vec![signer]), Some(vec![network]), @@ -1318,8 +1385,15 @@ mod tests { async fn test_create_relayer_nonexistent_notification() { let network = create_mock_network(); let signer = create_mock_signer(); - let app_state = - create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + let app_state = create_mock_app_state( + None, + None, + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; let request = create_test_relayer_create_request( Some("test-relayer".to_string()), @@ -1346,7 +1420,8 @@ mod tests { let relayer1 = create_mock_relayer("relayer-1".to_string(), false); let relayer2 = create_mock_relayer("relayer-2".to_string(), false); let app_state = - create_mock_app_state(Some(vec![relayer1, relayer2]), None, None, None, None).await; + create_mock_app_state(None, Some(vec![relayer1, relayer2]), None, None, None, None) + .await; let query = PaginationQuery { page: 1, @@ -1370,7 +1445,7 @@ mod tests { #[actix_web::test] async fn test_list_relayers_empty() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let query = PaginationQuery { page: 1, @@ -1397,7 +1472,8 @@ mod tests { #[actix_web::test] async fn test_get_relayer_success() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let result = get_relayer( "test-relayer".to_string(), @@ -1420,7 +1496,7 @@ mod tests { #[actix_web::test] async fn test_get_relayer_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let result = get_relayer( "nonexistent".to_string(), @@ -1441,7 +1517,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_success() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "name": "Updated Relayer Name", @@ -1472,7 +1549,8 @@ mod tests { async fn test_update_relayer_system_disabled() { let mut relayer = create_mock_relayer("disabled-relayer".to_string(), false); relayer.system_disabled = true; - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "name": "Updated Name" @@ -1496,7 +1574,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_invalid_patch() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "invalid_field": "value" @@ -1519,7 +1598,7 @@ mod tests { #[actix_web::test] async fn test_update_relayer_nonexistent() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let patch = serde_json::json!({ "name": "Updated Name" @@ -1543,7 +1622,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_set_evm_policies() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "policies": { @@ -1581,7 +1661,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_partial_policy_update() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; // First update with some policies let patch1 = serde_json::json!({ @@ -1603,7 +1684,8 @@ mod tests { // Create fresh app state for second update test let relayer2 = create_mock_relayer("test-relayer".to_string(), false); - let app_state2 = create_mock_app_state(Some(vec![relayer2]), None, None, None, None).await; + let app_state2 = + create_mock_app_state(None, Some(vec![relayer2]), None, None, None, None).await; // Second update with only gas_price_cap change let patch2 = serde_json::json!({ @@ -1637,7 +1719,8 @@ mod tests { async fn test_update_relayer_unset_notification() { let mut relayer = create_mock_relayer("test-relayer".to_string(), false); relayer.notification_id = Some("test-notification".to_string()); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "notification_id": null @@ -1669,7 +1752,8 @@ mod tests { url: "https://custom-rpc.example.com".to_string(), weight: 50, }]); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "custom_rpc_urls": null @@ -1697,7 +1781,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_set_custom_rpc_urls() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "custom_rpc_urls": [ @@ -1741,7 +1826,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_clear_policies() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "policies": null @@ -1769,7 +1855,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_invalid_policy_structure() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "policies": { @@ -1795,7 +1882,8 @@ mod tests { #[actix_web::test] async fn test_update_relayer_invalid_evm_policy_values() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "policies": { @@ -1823,7 +1911,8 @@ mod tests { async fn test_update_relayer_multiple_fields_at_once() { let mut relayer = create_mock_relayer("test-relayer".to_string(), false); relayer.notification_id = Some("old-notification".to_string()); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let patch = serde_json::json!({ "name": "Multi-Update Relayer", @@ -1884,7 +1973,7 @@ mod tests { solana_relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()); let app_state = - create_mock_app_state(Some(vec![solana_relayer]), None, None, None, None).await; + create_mock_app_state(None, Some(vec![solana_relayer]), None, None, None, None).await; let patch = serde_json::json!({ "policies": { @@ -1937,7 +2026,8 @@ mod tests { #[actix_web::test] async fn test_delete_relayer_success() { let relayer = create_mock_relayer("test-relayer".to_string(), false); - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let result = delete_relayer( "test-relayer".to_string(), @@ -1964,6 +2054,7 @@ mod tests { transaction.id = "test-tx".to_string(); transaction.relayer_id = "relayer-with-tx".to_string(); let app_state = create_mock_app_state( + None, Some(vec![relayer]), None, None, @@ -1990,7 +2081,7 @@ mod tests { #[actix_web::test] async fn test_delete_relayer_nonexistent() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let result = delete_relayer( "nonexistent-relayer".to_string(), @@ -2015,6 +2106,7 @@ mod tests { let mut relayer = create_mock_relayer("test-relayer".to_string(), false); relayer.network_type = NetworkType::Stellar; let app_state = create_mock_app_state( + None, Some(vec![relayer]), Some(vec![signer]), Some(vec![network]), @@ -2042,7 +2134,7 @@ mod tests { #[actix_web::test] async fn test_sign_transaction_relayer_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignTransactionRequest::Stellar(SignTransactionRequestStellar { unsigned_xdr: "test-unsigned-xdr".to_string(), @@ -2067,7 +2159,8 @@ mod tests { async fn test_sign_transaction_relayer_disabled() { let mut relayer = create_mock_relayer("disabled-relayer".to_string(), false); relayer.paused = true; - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let request = SignTransactionRequest::Stellar(SignTransactionRequestStellar { unsigned_xdr: "test-unsigned-xdr".to_string(), @@ -2092,7 +2185,8 @@ mod tests { async fn test_sign_transaction_system_disabled() { let mut relayer = create_mock_relayer("system-disabled-relayer".to_string(), false); relayer.system_disabled = true; - let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + let app_state = + create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; let request = SignTransactionRequest::Stellar(SignTransactionRequestStellar { unsigned_xdr: "test-unsigned-xdr".to_string(), diff --git a/src/api/controllers/signer.rs b/src/api/controllers/signer.rs index 1ea38f803..46ef1bb08 100644 --- a/src/api/controllers/signer.rs +++ b/src/api/controllers/signer.rs @@ -15,8 +15,8 @@ use crate::{ SignerResponse, SignerUpdateRequest, ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, }; use actix_web::HttpResponse; @@ -32,9 +32,9 @@ use eyre::Result; /// # Returns /// /// A paginated list of signers. -pub async fn list_signers( +pub async fn list_signers( query: PaginationQuery, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -45,6 +45,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let signers = state.signer_repository.list_paginated(query).await?; @@ -70,9 +71,9 @@ where /// # Returns /// /// The signer details or an error if not found. -pub async fn get_signer( +pub async fn get_signer( signer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -83,6 +84,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let signer = state.signer_repository.get_by_id(signer_id).await?; @@ -107,9 +109,9 @@ where /// (keys, credentials, etc.) should be provided through configuration files or /// other secure channels. This is a security measure to prevent sensitive data /// from being transmitted through API requests. -pub async fn create_signer( +pub async fn create_signer( request: SignerCreateRequest, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -120,6 +122,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // Convert request to domain model (validates automatically and includes placeholder config) let signer = Signer::try_from(request)?; @@ -149,10 +152,10 @@ where /// /// Signer updates are not supported for security reasons. To modify a signer, /// delete the existing one and create a new signer with the desired configuration. -pub async fn update_signer( +pub async fn update_signer( _signer_id: String, _request: SignerUpdateRequest, - _state: ThinDataAppState, + _state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -163,6 +166,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { Err(ApiError::BadRequest( "Signer updates are not allowed for security reasons. Please delete the existing signer and create a new one with the desired configuration.".to_string() @@ -185,9 +189,9 @@ where /// This endpoint ensures that signers cannot be deleted if they are still being /// used by any relayers. This prevents breaking existing relayer configurations /// and maintains system integrity. -pub async fn delete_signer( +pub async fn delete_signer( signer_id: String, - state: ThinDataAppState, + state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -198,6 +202,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // First check if the signer exists let _signer = state.signer_repository.get_by_id(signer_id.clone()).await?; @@ -308,7 +313,7 @@ mod tests { #[actix_web::test] async fn test_list_signers_empty() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let query = PaginationQuery { page: 1, per_page: 10, @@ -332,7 +337,7 @@ mod tests { #[actix_web::test] async fn test_list_signers_with_data() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create test signers let signer1 = create_test_signer_model("test-1", SignerType::Local); @@ -369,7 +374,7 @@ mod tests { #[actix_web::test] async fn test_get_signer_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test signer let signer = create_test_signer_model("test-signer", SignerType::Local); @@ -402,7 +407,7 @@ mod tests { #[actix_web::test] async fn test_get_signer_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let result = get_signer( "non-existent".to_string(), @@ -417,7 +422,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_test_type_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = create_test_signer_create_request( Some("new-test-signer".to_string()), @@ -444,14 +449,14 @@ mod tests { #[actix_web::test] async fn test_create_signer_with_valid_configs() { // Test Local signer with valid key - let app_state1 = create_mock_app_state(None, None, None, None, None).await; + let app_state1 = create_mock_app_state(None, None, None, None, None, None).await; let request = create_test_signer_create_request(Some("local-test".to_string()), SignerType::Local); let result = create_signer(request, actix_web::web::ThinData(app_state1)).await; assert!(result.is_ok(), "Local signer with valid key should succeed"); // Test AWS KMS signer with valid config - let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let app_state2 = create_mock_app_state(None, None, None, None, None, None).await; let request = create_test_signer_create_request(Some("aws-test".to_string()), SignerType::AwsKms); let result = create_signer(request, actix_web::web::ThinData(app_state2)).await; @@ -463,7 +468,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_local_with_valid_key() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("local-signer-test".to_string()), @@ -492,7 +497,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_aws_kms_comprehensive() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("aws-kms-signer".to_string()), @@ -524,7 +529,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_vault() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("vault-signer".to_string()), @@ -558,7 +563,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_vault_transit() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; use crate::models::{ SignerConfigRequest, SignerTypeRequest, VaultTransitSignerRequestConfig, @@ -596,7 +601,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_turnkey() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("turnkey-signer".to_string()), @@ -629,7 +634,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_google_cloud_kms() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("gcp-kms-signer".to_string()), @@ -675,7 +680,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_auto_generated_id() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: None, // Let the system generate an ID @@ -705,7 +710,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_invalid_local_key() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("invalid-key-signer".to_string()), @@ -727,7 +732,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_invalid_vault_address() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("invalid-vault-signer".to_string()), @@ -754,7 +759,7 @@ mod tests { #[actix_web::test] async fn test_create_signer_empty_aws_kms_key_id() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let request = SignerCreateRequest { id: Some("empty-key-id-signer".to_string()), @@ -777,7 +782,7 @@ mod tests { #[actix_web::test] async fn test_update_signer_not_allowed() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test signer let signer = create_test_signer_model("test-signer", SignerType::Local); @@ -804,7 +809,7 @@ mod tests { #[actix_web::test] async fn test_update_signer_always_fails() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let update_request = create_test_signer_update_request(); @@ -826,7 +831,7 @@ mod tests { #[actix_web::test] async fn test_delete_signer_success() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test signer let signer = create_test_signer_model("test-signer", SignerType::Local); @@ -853,7 +858,7 @@ mod tests { #[actix_web::test] async fn test_delete_signer_blocked_by_connected_relayers() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test signer let signer = create_test_signer_model("connected-signer", SignerType::Local); @@ -898,7 +903,7 @@ mod tests { #[actix_web::test] async fn test_delete_signer_not_found() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let result = delete_signer( "non-existent".to_string(), @@ -913,7 +918,7 @@ mod tests { #[actix_web::test] async fn test_delete_signer_after_relayer_removed() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test signer let signer = create_test_signer_model("cleanup-signer", SignerType::Local); @@ -946,7 +951,7 @@ mod tests { assert!(result.is_err()); // Create new app state for second test (since app_state was consumed) - let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let app_state2 = create_mock_app_state(None, None, None, None, None, None).await; // Re-create the signer in the new state let signer2 = create_test_signer_model("cleanup-signer", SignerType::Local); @@ -981,7 +986,7 @@ mod tests { #[actix_web::test] async fn test_delete_signer_with_multiple_relayers() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create a test signer let signer = create_test_signer_model("multi-relayer-signer", SignerType::AwsKms); @@ -1064,7 +1069,7 @@ mod tests { #[actix_web::test] async fn test_delete_signer_with_some_relayers_using_different_signer() { - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; // Create two test signers let signer1 = create_test_signer_model("signer-to-delete", SignerType::Local); @@ -1126,7 +1131,7 @@ mod tests { } // Try to delete the second signer - should succeed (no relayers using it in our test) - let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let app_state2 = create_mock_app_state(None, None, None, None, None, None).await; let signer2_recreated = create_test_signer_model("other-signer", SignerType::AwsKms); app_state2 .signer_repository diff --git a/src/api/routes/api_keys.rs b/src/api/routes/api_keys.rs new file mode 100644 index 000000000..d518a5f56 --- /dev/null +++ b/src/api/routes/api_keys.rs @@ -0,0 +1,139 @@ +//! This module defines the HTTP routes for api keys operations. +//! The routes are integrated with the Actix-web framework and interact with the api key controller. +use crate::{ + api::controllers::api_key, + models::{ApiKeyRequest, DefaultAppState, PaginationQuery}, +}; +use actix_web::{delete, get, post, web, Responder}; + +/// List API keys +#[get("/api-keys")] +async fn list_api_keys( + query: web::Query, + data: web::ThinData, +) -> impl Responder { + api_key::list_api_keys(query.into_inner(), data).await +} + +#[get("/api-keys/{api_key_id}/permissions")] +async fn get_api_key_permissions( + api_key_id: web::Path, + data: web::ThinData, +) -> impl Responder { + api_key::get_api_key_permissions(api_key_id.into_inner(), data).await +} + +/// Create a new API key +#[post("/api-keys")] +async fn create_api_key( + req: web::Json, + data: web::ThinData, +) -> impl Responder { + api_key::create_api_key(req.into_inner(), data).await +} + +#[delete("/api-keys/{api_key_id}")] +async fn delete_api_key( + api_key_id: web::Path, + data: web::ThinData, +) -> impl Responder { + api_key::delete_api_key(api_key_id.into_inner(), data).await +} + +/// Initializes the routes for api keys. +pub fn init(cfg: &mut web::ServiceConfig) { + // Register routes with literal segments before routes with path parameters + cfg.service(create_api_key); // /api-keys + cfg.service(list_api_keys); // /api-keys + cfg.service(get_api_key_permissions); // /api-keys/{api_key_id}/permissions + cfg.service(delete_api_key); // /api-keys/{api_key_id} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::mocks::mockutils::create_mock_app_state; + use actix_web::{http::StatusCode, test, web, App}; + + #[actix_web::test] + async fn test_api_key_routes_are_registered() { + // Arrange - Create app with API key routes + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test GET /api-keys - should not return 404 (route exists) + let req = test::TestRequest::get().uri("/api-keys").to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /api-keys route not registered" + ); + + // Test POST /api-keys - should not return 404 + let req = test::TestRequest::post() + .uri("/api-keys") + .set_json(serde_json::json!({ + "name": "Test API Key", + "permissions": ["relayer:all:execute"], + "allowed_origins": ["*"] + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "POST /api-keys route not registered" + ); + + // Test GET /api-keys/{api_key_id}/permissions - should not return 404 + let req = test::TestRequest::get() + .uri("/api-keys/test-id/permissions") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /api-keys/{{api_key_id}}/permissions route not registered" + ); + + // Test DELETE /api-keys/{api_key_id} - should not return 404 + let req = test::TestRequest::delete() + .uri("/api-keys/test-id") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "DELETE /api-keys/{{api_key_id}} route not registered" + ); + } + + #[actix_web::test] + async fn test_api_key_routes_with_query_params() { + // Arrange - Create app with API key routes + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test GET /api-keys with pagination parameters + let req = test::TestRequest::get() + .uri("/api-keys?page=1&per_page=10") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /api-keys with query params route not registered" + ); + } +} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 02a7e3c1f..bdd883aa6 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -9,6 +9,7 @@ //! * `/notifications` - Notification management endpoints //! * `/signers` - Signer management endpoints +pub mod api_keys; pub mod docs; pub mod health; pub mod metrics; @@ -24,5 +25,6 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .configure(plugin::init) .configure(metrics::init) .configure(notification::init) - .configure(signer::init); + .configure(signer::init) + .configure(api_keys::init); } diff --git a/src/api/routes/notification.rs b/src/api/routes/notification.rs index e8dcee1b3..02919b821 100644 --- a/src/api/routes/notification.rs +++ b/src/api/routes/notification.rs @@ -75,7 +75,7 @@ mod tests { #[actix_web::test] async fn test_notification_routes_are_registered() { // Arrange - Create app with notification routes - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let app = test::init_service( App::new() .app_data(web::Data::new(app_state)) diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index f0cb79592..782dbc508 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -222,15 +222,16 @@ mod tests { config::{EvmNetworkConfig, NetworkConfigCommon}, jobs::MockJobProducerTrait, models::{ - AppState, EvmTransactionData, LocalSignerConfigStorage, NetworkConfigData, - NetworkRepoModel, NetworkTransactionData, NetworkType, RelayerEvmPolicy, - RelayerNetworkPolicy, RelayerRepoModel, SignerConfigStorage, SignerRepoModel, - TransactionRepoModel, TransactionStatus, U256, + ApiKeyRepoModel, AppState, EvmTransactionData, LocalSignerConfigStorage, + NetworkConfigData, NetworkRepoModel, NetworkTransactionData, NetworkType, + RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, SecretString, + SignerConfigStorage, SignerRepoModel, TransactionRepoModel, TransactionStatus, U256, }, repositories::{ - NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, - RelayerRepositoryStorage, Repository, SignerRepositoryStorage, - TransactionCounterRepositoryStorage, TransactionRepositoryStorage, + ApiKeyRepositoryStorage, ApiKeyRepositoryTrait, NetworkRepositoryStorage, + NotificationRepositoryStorage, PluginRepositoryStorage, RelayerRepositoryStorage, + Repository, SignerRepositoryStorage, TransactionCounterRepositoryStorage, + TransactionRepositoryStorage, }, }; use actix_web::{http::StatusCode, test, App}; @@ -246,11 +247,13 @@ mod tests { SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, + ApiKeyRepositoryStorage, > { let relayer_repo = Arc::new(RelayerRepositoryStorage::new_in_memory()); let transaction_repo = Arc::new(TransactionRepositoryStorage::new_in_memory()); let signer_repo = Arc::new(SignerRepositoryStorage::new_in_memory()); let network_repo = Arc::new(NetworkRepositoryStorage::new_in_memory()); + let api_key_repo = Arc::new(ApiKeyRepositoryStorage::new_in_memory()); // Create test entities so routes don't return 404 @@ -338,6 +341,17 @@ mod tests { }; transaction_repo.create(test_transaction).await.unwrap(); + // Create test api key + let test_api_key = ApiKeyRepoModel { + id: "test-api-key".to_string(), + name: "Test API Key".to_string(), + value: SecretString::new("test-value"), + permissions: vec!["test-permission".to_string()], + created_at: chrono::Utc::now().to_rfc3339(), + allowed_origins: vec!["*".to_string()], + }; + api_key_repo.create(test_api_key).await.unwrap(); + AppState { relayer_repository: relayer_repo, transaction_repository: transaction_repo, @@ -349,6 +363,7 @@ mod tests { ), job_producer: Arc::new(MockJobProducerTrait::new()), plugin_repository: Arc::new(PluginRepositoryStorage::new_in_memory()), + api_key_repository: api_key_repo, } } diff --git a/src/api/routes/signer.rs b/src/api/routes/signer.rs index f839a42bc..e6adbf35c 100644 --- a/src/api/routes/signer.rs +++ b/src/api/routes/signer.rs @@ -72,7 +72,7 @@ mod tests { #[actix_web::test] async fn test_signer_routes_are_registered() { // Arrange - Create app with signer routes - let app_state = create_mock_app_state(None, None, None, None, None).await; + let app_state = create_mock_app_state(None, None, None, None, None, None).await; let app = test::init_service( App::new() .app_data(web::Data::new(app_state)) diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index b0f730650..f10c64d00 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -6,13 +6,13 @@ use crate::{ config::{Config, RepositoryStorageType, ServerConfig}, jobs::JobProducerTrait, models::{ - NetworkRepoModel, NotificationRepoModel, PluginModel, Relayer, RelayerRepoModel, - Signer as SignerDomainModel, SignerFileConfig, SignerRepoModel, ThinDataAppState, - TransactionRepoModel, + ApiKeyRepoModel, NetworkRepoModel, NotificationRepoModel, PluginModel, Relayer, + RelayerRepoModel, Signer as SignerDomainModel, SignerFileConfig, SignerRepoModel, + ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, services::{Signer as SignerService, SignerFactory}, }; @@ -20,10 +20,41 @@ use color_eyre::{eyre::WrapErr, Report, Result}; use futures::future::try_join_all; use tracing::info; +async fn process_api_key( + server_config: &ServerConfig, + app_state: &ThinDataAppState, +) -> Result<()> +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let api_key_model = ApiKeyRepoModel::new( + "default".to_string(), + server_config.api_key.clone(), + vec!["*".to_string()], + vec!["*".to_string()], + ); + + app_state + .api_key_repository + .create(api_key_model) + .await + .wrap_err("Failed to create api key repository entry")?; + + Ok(()) +} + /// Process all plugins from the config file and store them in the repository. -async fn process_plugins( +async fn process_plugins( config_file: &Config, - app_state: &ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -34,6 +65,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { if let Some(plugins) = &config_file.plugins { let plugin_futures = plugins.iter().map(|plugin| async { @@ -75,9 +107,9 @@ async fn process_signer(signer: &SignerFileConfig) -> Result { /// 2. Store the resulting repository model /// /// This function processes signers in parallel using futures. -async fn process_signers( +async fn process_signers( config_file: &Config, - app_state: &ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -88,6 +120,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let signer_futures = config_file.signers.iter().map(|signer| async { let signer_repo_model = process_signer(signer).await?; @@ -113,9 +146,9 @@ where /// 2. Store the resulting model in the repository /// /// This function processes notifications in parallel using futures. -async fn process_notifications( +async fn process_notifications( config_file: &Config, - app_state: &ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -126,6 +159,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let notification_futures = config_file.notifications.iter().map(|notification| async { let notification_repo_model = NotificationRepoModel::try_from(notification.clone()) @@ -152,9 +186,9 @@ where /// 2. Store the resulting model in the repository /// /// This function processes networks in parallel using futures. -async fn process_networks( +async fn process_networks( config_file: &Config, - app_state: &ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -165,6 +199,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let network_futures = config_file.networks.iter().map(|network| async move { let network_repo_model = NetworkRepoModel::try_from(network.clone())?; @@ -193,9 +228,9 @@ where /// 5. Store the resulting model in the repository /// /// This function processes relayers in parallel using futures. -async fn process_relayers( +async fn process_relayers( config_file: &Config, - app_state: &ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -206,6 +241,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let signers = app_state.signer_repository.list_all().await?; @@ -248,8 +284,8 @@ where /// /// This function checks if any of the main repository list keys exist in Redis. /// If they exist, it means Redis already contains data from a previous configuration load. -async fn is_redis_populated( - app_state: &ThinDataAppState, +async fn is_redis_populated( + app_state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -260,6 +296,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { if app_state.relayer_repository.has_entries().await? { return Ok(true); @@ -295,10 +332,10 @@ where /// 2. Process notifications /// 3. Process networks /// 4. Process relayers -pub async fn process_config_file( +pub async fn process_config_file( config_file: Config, server_config: Arc, - app_state: &ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -309,6 +346,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let should_process_config_file = match server_config.repository_storage_type { RepositoryStorageType::InMemory => true, @@ -330,6 +368,7 @@ where app_state.notification_repository.drop_all_entries().await?; app_state.network_repository.drop_all_entries().await?; app_state.plugin_repository.drop_all_entries().await?; + app_state.api_key_repository.drop_all_entries().await?; } if should_process_config_file { @@ -339,6 +378,7 @@ where process_notifications(&config_file, app_state).await?; process_networks(&config_file, app_state).await?; process_relayers(&config_file, app_state).await?; + process_api_key(&server_config, app_state).await?; } Ok(()) } @@ -354,14 +394,16 @@ mod tests { relayer::RelayerFileConfig, AppState, AwsKmsSignerFileConfig, GoogleCloudKmsKeyFileConfig, GoogleCloudKmsServiceAccountFileConfig, GoogleCloudKmsSignerFileConfig, LocalSignerFileConfig, NetworkType, NotificationConfig, - NotificationType, PlainOrEnvValue, SecretString, SignerConfigStorage, SignerFileConfig, - SignerFileConfigEnum, VaultSignerFileConfig, VaultTransitSignerFileConfig, + NotificationType, PaginationQuery, PlainOrEnvValue, SecretString, SignerConfigStorage, + SignerFileConfig, SignerFileConfigEnum, VaultSignerFileConfig, + VaultTransitSignerFileConfig, }, repositories::{ - InMemoryNetworkRepository, InMemoryNotificationRepository, InMemoryPluginRepository, - InMemorySignerRepository, InMemoryTransactionCounter, InMemoryTransactionRepository, - NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, - RelayerRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, + ApiKeyRepositoryStorage, InMemoryApiKeyRepository, InMemoryNetworkRepository, + InMemoryNotificationRepository, InMemoryPluginRepository, InMemorySignerRepository, + InMemoryTransactionCounter, InMemoryTransactionRepository, NetworkRepositoryStorage, + NotificationRepositoryStorage, PluginRepositoryStorage, RelayerRepositoryStorage, + SignerRepositoryStorage, TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, utils::mocks::mockutils::{ @@ -383,6 +425,7 @@ mod tests { SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, + ApiKeyRepositoryStorage, > { // Create a mock job producer let mut mock_job_producer = MockJobProducerTrait::new(); @@ -415,6 +458,7 @@ mod tests { ), job_producer: Arc::new(mock_job_producer), plugin_repository: Arc::new(PluginRepositoryStorage::new_in_memory()), + api_key_repository: Arc::new(ApiKeyRepositoryStorage::new_in_memory()), } } @@ -1080,6 +1124,30 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_process_api_key() -> Result<()> { + let server_config = Arc::new(crate::utils::mocks::mockutils::create_test_server_config( + RepositoryStorageType::InMemory, + )); + let app_state = ThinData(create_test_app_state()); + + process_api_key(&server_config, &app_state).await?; + + let pagination_query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let stored_api_keys = app_state + .api_key_repository + .list_paginated(pagination_query) + .await?; + assert_eq!(stored_api_keys.items.len(), 1); + assert_eq!(stored_api_keys.items[0].name, "default"); + + Ok(()) + } + #[tokio::test] async fn test_process_config_file() -> Result<()> { // Create test signers, relayers, and notifications @@ -1137,6 +1205,7 @@ mod tests { )); let transaction_counter = Arc::new(InMemoryTransactionCounter::default()); let plugin_repo = Arc::new(InMemoryPluginRepository::default()); + let api_key_repo = Arc::new(InMemoryApiKeyRepository::default()); // Create a mock job producer let mut mock_job_producer = MockJobProducerTrait::new(); @@ -1164,6 +1233,7 @@ mod tests { transaction_counter_store: transaction_counter.clone(), job_producer: job_producer.clone(), plugin_repository: plugin_repo.clone(), + api_key_repository: api_key_repo.clone(), }); // Process the entire config file diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index 6007c1092..c739311b1 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -7,9 +7,9 @@ use crate::{ jobs::{self, Queue}, models::{AppState, DefaultAppState}, repositories::{ - NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, - RelayerRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, - TransactionRepositoryStorage, + ApiKeyRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, + PluginRepositoryStorage, RelayerRepositoryStorage, SignerRepositoryStorage, + TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, utils::initialize_redis_connection, }; @@ -26,6 +26,7 @@ pub struct RepositoryCollection { pub network: Arc, pub transaction_counter: Arc, pub plugin: Arc, + pub api_key: Arc, } /// Initializes repositories based on the server configuration @@ -45,6 +46,7 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { warn!("⚠️ Redis repository storage support is experimental. Use with caution."); @@ -82,6 +84,10 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result= 1); assert!(Arc::strong_count(&repositories.transaction_counter) >= 1); assert!(Arc::strong_count(&repositories.plugin) >= 1); + assert!(Arc::strong_count(&repositories.api_key) >= 1); } #[tokio::test] @@ -164,11 +173,13 @@ mod tests { let relayer = create_mock_relayer("test-relayer".to_string(), false); let signer = create_mock_signer(); let network = create_mock_network(); + let api_key = create_mock_api_key(); // Test creating and retrieving items repositories.relayer.create(relayer.clone()).await.unwrap(); repositories.signer.create(signer.clone()).await.unwrap(); repositories.network.create(network.clone()).await.unwrap(); + repositories.api_key.create(api_key.clone()).await.unwrap(); let retrieved_relayer = repositories .relayer @@ -185,10 +196,16 @@ mod tests { .get_by_id("test".to_string()) .await .unwrap(); + let retrieved_api_key = repositories + .api_key + .get_by_id("test-api-key") + .await + .unwrap(); assert_eq!(retrieved_relayer.id, "test-relayer"); assert_eq!(retrieved_signer.id, "test"); assert_eq!(retrieved_network.id, "test"); + assert_eq!(retrieved_api_key.unwrap().id, "test-api-key"); } #[tokio::test] diff --git a/src/domain/relayer/mod.rs b/src/domain/relayer/mod.rs index 11beca8ba..1254e0d1d 100644 --- a/src/domain/relayer/mod.rs +++ b/src/domain/relayer/mod.rs @@ -26,8 +26,8 @@ use crate::{ StellarNetwork, TransactionError, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, services::{ get_network_provider, EvmSignerFactory, StellarSignerFactory, TransactionCounterService, @@ -355,12 +355,13 @@ pub trait RelayerFactoryTrait< SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, > { async fn create_relayer( relayer: RelayerRepoModel, signer: SignerRepoModel, - state: &ThinData>, + state: &ThinData>, ) -> Result, RelayerError>; } @@ -376,12 +377,13 @@ impl< SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, - > RelayerFactoryTrait for RelayerFactory + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + > RelayerFactoryTrait for RelayerFactory { async fn create_relayer( relayer: RelayerRepoModel, signer: SignerRepoModel, - state: &ThinData>, + state: &ThinData>, ) -> Result, RelayerError> { match relayer.network_type { NetworkType::Evm => { diff --git a/src/domain/relayer/util.rs b/src/domain/relayer/util.rs index 62a4a347b..77084d3c3 100644 --- a/src/domain/relayer/util.rs +++ b/src/domain/relayer/util.rs @@ -19,8 +19,8 @@ use crate::{ SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, }; @@ -37,9 +37,9 @@ use super::NetworkRelayer; /// /// * `Result` - Returns a `RelayerRepoModel` on success, or an /// `ApiError` on failure. -pub async fn get_relayer_by_id( +pub async fn get_relayer_by_id( relayer_id: String, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -50,6 +50,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { state .relayer_repository @@ -69,9 +70,9 @@ where /// /// * `Result` - Returns a `NetworkRelayer` on success, or an `ApiError` /// on failure. -pub async fn get_network_relayer( +pub async fn get_network_relayer( relayer_id: String, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result, ApiError> where J: JobProducerTrait + Send + Sync + 'static, @@ -82,6 +83,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer_model = get_relayer_by_id(relayer_id.clone(), state).await?; let signer_model = state @@ -105,9 +107,9 @@ where /// /// * `Result` - Returns a `NetworkRelayer` on success, or an `ApiError` /// on failure. -pub async fn get_network_relayer_by_model( +pub async fn get_network_relayer_by_model( relayer_model: RelayerRepoModel, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result, ApiError> where J: JobProducerTrait + Send + Sync + 'static, @@ -118,6 +120,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let signer_model = state .signer_repository diff --git a/src/domain/transaction/util.rs b/src/domain/transaction/util.rs index 68d541c2e..e2f450551 100644 --- a/src/domain/transaction/util.rs +++ b/src/domain/transaction/util.rs @@ -13,8 +13,8 @@ use crate::{ SignerRepoModel, ThinDataAppState, TransactionError, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, }; @@ -31,9 +31,9 @@ use super::{NetworkTransaction, RelayerTransactionFactory}; /// /// A `Result` containing a `TransactionRepoModel` if successful, or an `ApiError` if an error /// occurs. -pub async fn get_transaction_by_id( +pub async fn get_transaction_by_id( transaction_id: String, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -44,6 +44,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { state .transaction_repository diff --git a/src/models/api_key/mod.rs b/src/models/api_key/mod.rs new file mode 100644 index 000000000..2845386b9 --- /dev/null +++ b/src/models/api_key/mod.rs @@ -0,0 +1,8 @@ +mod request; +pub use request::*; + +mod response; +pub use response::*; + +mod repository; +pub use repository::*; diff --git a/src/models/api_key/repository.rs b/src/models/api_key/repository.rs new file mode 100644 index 000000000..afc6e3e3a --- /dev/null +++ b/src/models/api_key/repository.rs @@ -0,0 +1,94 @@ +use crate::{ + models::{ApiError, ApiKeyRequest, SecretString}, + utils::{deserialize_secret_string, serialize_secret_string}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ApiKeyRepoModel { + pub id: String, + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub value: SecretString, + pub name: String, + pub allowed_origins: Vec, + pub created_at: String, + pub permissions: Vec, +} + +impl ApiKeyRepoModel { + pub fn new( + name: String, + value: SecretString, + permissions: Vec, + allowed_origins: Vec, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + value, + name, + permissions, + allowed_origins, + created_at: Utc::now().to_string(), + } + } +} + +impl TryFrom for ApiKeyRepoModel { + type Error = ApiError; + + fn try_from(request: ApiKeyRequest) -> Result { + let allowed_origins = request.allowed_origins.unwrap_or(vec!["*".to_string()]); + + Ok(Self { + id: Uuid::new_v4().to_string(), + value: SecretString::new(&Uuid::new_v4().to_string()), + name: request.name, + permissions: request.permissions, + allowed_origins, + created_at: Utc::now().to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_key_repo_model_new() { + let api_key_repo_model = ApiKeyRepoModel::new( + "test-name".to_string(), + SecretString::new("test-value"), + vec!["relayer:all:execute".to_string()], + vec!["*".to_string()], + ); + assert_eq!(api_key_repo_model.name, "test-name"); + assert_eq!(api_key_repo_model.value, SecretString::new("test-value")); + assert_eq!( + api_key_repo_model.permissions, + vec!["relayer:all:execute".to_string()] + ); + assert_eq!(api_key_repo_model.allowed_origins, vec!["*".to_string()]); + } + + #[test] + fn test_api_key_repo_model_try_from() { + let api_key_request = ApiKeyRequest { + name: "test-name".to_string(), + permissions: vec!["relayer:all:execute".to_string()], + allowed_origins: Some(vec!["*".to_string()]), + }; + let api_key_repo_model = ApiKeyRepoModel::try_from(api_key_request).unwrap(); + assert_eq!(api_key_repo_model.name, "test-name"); + assert_eq!( + api_key_repo_model.permissions, + vec!["relayer:all:execute".to_string()] + ); + assert_eq!(api_key_repo_model.allowed_origins, vec!["*".to_string()]); + } +} diff --git a/src/models/api_key/request.rs b/src/models/api_key/request.rs new file mode 100644 index 000000000..4373532c0 --- /dev/null +++ b/src/models/api_key/request.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct ApiKeyRequest { + pub name: String, + pub permissions: Vec, + pub allowed_origins: Option>, +} diff --git a/src/models/api_key/response.rs b/src/models/api_key/response.rs new file mode 100644 index 000000000..bad7367cb --- /dev/null +++ b/src/models/api_key/response.rs @@ -0,0 +1,25 @@ +use crate::models::{ApiError, ApiKeyRepoModel}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ApiKeyResponse { + pub id: String, + pub name: String, + pub allowed_origins: Vec, + pub created_at: String, + pub permissions: Vec, +} + +impl TryFrom for ApiKeyResponse { + type Error = ApiError; + + fn try_from(api_key: ApiKeyRepoModel) -> Result { + Ok(ApiKeyResponse { + id: api_key.id, + name: api_key.name, + allowed_origins: api_key.allowed_origins, + created_at: api_key.created_at, + permissions: api_key.permissions, + }) + } +} diff --git a/src/models/app_state.rs b/src/models/app_state.rs index 4ad8a474a..d122badd6 100644 --- a/src/models/app_state.rs +++ b/src/models/app_state.rs @@ -12,11 +12,11 @@ use crate::{ TransactionRepoModel, }, repositories::{ - NetworkRepository, NetworkRepositoryStorage, NotificationRepositoryStorage, - PluginRepositoryStorage, PluginRepositoryTrait, RelayerRepository, - RelayerRepositoryStorage, Repository, SignerRepositoryStorage, - TransactionCounterRepositoryStorage, TransactionCounterTrait, TransactionRepository, - TransactionRepositoryStorage, + ApiKeyRepositoryStorage, ApiKeyRepositoryTrait, NetworkRepository, + NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, + PluginRepositoryTrait, RelayerRepository, RelayerRepositoryStorage, Repository, + SignerRepositoryStorage, TransactionCounterRepositoryStorage, TransactionCounterTrait, + TransactionRepository, TransactionRepositoryStorage, }, }; @@ -32,6 +32,7 @@ pub struct AppState< SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, > { /// Repository for managing relayer data. pub relayer_repository: Arc, @@ -49,11 +50,13 @@ pub struct AppState< pub job_producer: Arc, /// Repository for managing plugins. pub plugin_repository: Arc, + /// Repository for managing api keys. + pub api_key_repository: Arc, } /// type alias for the app state wrapped in a ThinData to avoid clippy warnings -pub type ThinDataAppState = - ThinData>; +pub type ThinDataAppState = + ThinData>; pub type DefaultAppState = AppState< JobProducer, @@ -64,6 +67,7 @@ pub type DefaultAppState = AppState< SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, + ApiKeyRepositoryStorage, >; impl< @@ -75,7 +79,8 @@ impl< SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, - > AppState + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + > AppState { /// Returns a clone of the relayer repository. /// @@ -148,6 +153,15 @@ impl< pub fn plugin_repository(&self) -> Arc { Arc::clone(&self.plugin_repository) } + + /// Returns a clone of the api key repository. + /// + /// # Returns + /// + /// An `Arc` pointing to the `InMemoryApiKeyRepository`. + pub fn api_key_repository(&self) -> Arc { + Arc::clone(&self.api_key_repository) + } } #[cfg(test)] @@ -166,6 +180,7 @@ mod tests { SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, + ApiKeyRepositoryStorage, > { // Create a mock job producer let mut mock_job_producer = MockJobProducerTrait::new(); @@ -198,6 +213,7 @@ mod tests { ), job_producer: Arc::new(mock_job_producer), plugin_repository: Arc::new(PluginRepositoryStorage::new_in_memory()), + api_key_repository: Arc::new(ApiKeyRepositoryStorage::new_in_memory()), } } @@ -271,4 +287,14 @@ mod tests { assert!(Arc::ptr_eq(&store1, &store2)); assert!(Arc::ptr_eq(&store1, &app_state.plugin_repository)); } + + #[test] + fn test_api_key_repository_getter() { + let app_state = create_test_app_state(); + let repo1 = app_state.api_key_repository(); + let repo2 = app_state.api_key_repository(); + + assert!(Arc::ptr_eq(&repo1, &repo2)); + assert!(Arc::ptr_eq(&repo1, &app_state.api_key_repository)); + } } diff --git a/src/models/mod.rs b/src/models/mod.rs index e44847aeb..579ed66ab 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -49,3 +49,6 @@ pub use plain_or_env_value::*; mod plugin; pub use plugin::*; + +mod api_key; +pub use api_key::*; diff --git a/src/repositories/api_key/api_key_in_memory.rs b/src/repositories/api_key/api_key_in_memory.rs new file mode 100644 index 000000000..0f0038f89 --- /dev/null +++ b/src/repositories/api_key/api_key_in_memory.rs @@ -0,0 +1,305 @@ +//! This module provides an in-memory implementation of api keys. +//! +//! The `InMemoryApiKeyRepository` struct is used to store and retrieve api keys +//! permissions. +use crate::{ + models::{ApiKeyRepoModel, PaginationQuery}, + repositories::{ApiKeyRepositoryTrait, PaginatedResult, RepositoryError}, +}; + +use async_trait::async_trait; + +use std::collections::HashMap; +use tokio::sync::{Mutex, MutexGuard}; + +#[derive(Debug)] +pub struct InMemoryApiKeyRepository { + store: Mutex>, +} + +impl Clone for InMemoryApiKeyRepository { + fn clone(&self) -> Self { + // Try to get the current data, or use empty HashMap if lock fails + let data = self + .store + .try_lock() + .map(|guard| guard.clone()) + .unwrap_or_else(|_| HashMap::new()); + + Self { + store: Mutex::new(data), + } + } +} + +impl InMemoryApiKeyRepository { + pub fn new() -> Self { + Self { + store: Mutex::new(HashMap::new()), + } + } + + async fn acquire_lock(lock: &Mutex) -> Result, RepositoryError> { + Ok(lock.lock().await) + } +} + +impl Default for InMemoryApiKeyRepository { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl ApiKeyRepositoryTrait for InMemoryApiKeyRepository { + async fn create(&self, api_key: ApiKeyRepoModel) -> Result { + let mut store = Self::acquire_lock(&self.store).await?; + store.insert(api_key.id.clone(), api_key.clone()); + Ok(api_key) + } + + async fn get_by_id(&self, id: &str) -> Result, RepositoryError> { + let store = Self::acquire_lock(&self.store).await?; + Ok(store.get(id).cloned()) + } + + async fn list_paginated( + &self, + query: PaginationQuery, + ) -> Result, RepositoryError> { + let total = self.count().await?; + let start = ((query.page - 1) * query.per_page) as usize; + + let items = self + .store + .lock() + .await + .values() + .skip(start) + .take(query.per_page as usize) + .cloned() + .collect(); + + Ok(PaginatedResult { + items, + total: total as u64, + page: query.page, + per_page: query.per_page, + }) + } + + async fn count(&self) -> Result { + let store = self.store.lock().await; + Ok(store.len()) + } + + async fn list_permissions(&self, api_key_id: &str) -> Result, RepositoryError> { + let store = self.store.lock().await; + let api_key = store + .get(api_key_id) + .ok_or(RepositoryError::NotFound(format!( + "Api key with id {} not found", + api_key_id + )))?; + Ok(api_key.permissions.clone()) + } + + async fn delete_by_id(&self, api_key_id: &str) -> Result<(), RepositoryError> { + let mut store = self.store.lock().await; + store.remove(api_key_id); + Ok(()) + } + + async fn has_entries(&self) -> Result { + let store = Self::acquire_lock(&self.store).await?; + Ok(!store.is_empty()) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + store.clear(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use std::sync::Arc; + + use crate::models::SecretString; + + use super::*; + + #[tokio::test] + async fn test_in_memory_api_key_repository() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + + // Test add and get_by_id + let api_key = ApiKeyRepoModel { + id: "test-api-key".to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }; + api_key_repository.create(api_key.clone()).await.unwrap(); + assert_eq!( + api_key_repository.get_by_id("test-api-key").await.unwrap(), + Some(api_key) + ); + } + + #[tokio::test] + async fn test_get_nonexistent_api_key() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + + let result = api_key_repository.get_by_id("test-api-key").await; + assert!(matches!(result, Ok(None))); + } + + #[tokio::test] + async fn test_get_by_id() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + + let api_key = ApiKeyRepoModel { + id: "test-api-key".to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }; + api_key_repository.create(api_key.clone()).await.unwrap(); + assert_eq!( + api_key_repository.get_by_id("test-api-key").await.unwrap(), + Some(api_key) + ); + } + + #[tokio::test] + async fn test_list_paginated_api_keys() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + + let api_key1 = ApiKeyRepoModel { + id: "test-api-key1".to_string(), + value: SecretString::new("test-value1"), + name: "test-name1".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }; + + let api_key2 = ApiKeyRepoModel { + id: "test-api-key2".to_string(), + value: SecretString::new("test-value2"), + name: "test-name2".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }; + + api_key_repository.create(api_key1.clone()).await.unwrap(); + api_key_repository.create(api_key2.clone()).await.unwrap(); + + let query = PaginationQuery { + page: 1, + per_page: 2, + }; + + let result = api_key_repository.list_paginated(query).await; + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.items.len(), 2); + } + + #[tokio::test] + async fn test_has_entries() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + assert!(!api_key_repository.has_entries().await.unwrap()); + api_key_repository + .create(ApiKeyRepoModel { + id: "test-api-key".to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }) + .await + .unwrap(); + + assert!(api_key_repository.has_entries().await.unwrap()); + api_key_repository.drop_all_entries().await.unwrap(); + assert!(!api_key_repository.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_delete_by_id_api_key() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + api_key_repository + .create(ApiKeyRepoModel { + id: "test-api-key".to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }) + .await + .unwrap(); + + assert!(api_key_repository.has_entries().await.unwrap()); + api_key_repository + .delete_by_id("test-api-key") + .await + .unwrap(); + assert!(!api_key_repository.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_list_permissions_api_key() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + api_key_repository + .create(ApiKeyRepoModel { + id: "test-api-key".to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec![ + "relayer:all:execute".to_string(), + "relayer:all:read".to_string(), + ], + created_at: Utc::now().to_string(), + }) + .await + .unwrap(); + + let permissions = api_key_repository + .list_permissions("test-api-key") + .await + .unwrap(); + assert_eq!(permissions, vec!["relayer:all:execute", "relayer:all:read"]); + } + + #[tokio::test] + async fn test_drop_all_entries() { + let api_key_repository = Arc::new(InMemoryApiKeyRepository::new()); + api_key_repository + .create(ApiKeyRepoModel { + id: "test-api-key".to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + }) + .await + .unwrap(); + + assert!(api_key_repository.has_entries().await.unwrap()); + api_key_repository.drop_all_entries().await.unwrap(); + assert!(!api_key_repository.has_entries().await.unwrap()); + } +} diff --git a/src/repositories/api_key/api_key_redis.rs b/src/repositories/api_key/api_key_redis.rs new file mode 100644 index 000000000..a923ab6f8 --- /dev/null +++ b/src/repositories/api_key/api_key_redis.rs @@ -0,0 +1,549 @@ +//! Redis-backed implementation of the ApiKeyRepository. + +use crate::models::{ApiKeyRepoModel, PaginationQuery, RepositoryError}; +use crate::repositories::redis_base::RedisRepository; +use crate::repositories::{ApiKeyRepositoryTrait, BatchRetrievalResult, PaginatedResult}; +use async_trait::async_trait; +use redis::aio::ConnectionManager; +use redis::AsyncCommands; +use std::fmt; +use std::sync::Arc; +use tracing::{debug, error, warn}; + +const API_KEY_PREFIX: &str = "apikey"; +const API_KEY_LIST_KEY: &str = "apikey_list"; + +#[derive(Clone)] +pub struct RedisApiKeyRepository { + pub client: Arc, + pub key_prefix: String, +} + +impl RedisRepository for RedisApiKeyRepository {} + +impl RedisApiKeyRepository { + pub fn new( + connection_manager: Arc, + key_prefix: String, + ) -> Result { + if key_prefix.is_empty() { + return Err(RepositoryError::InvalidData( + "Redis key prefix cannot be empty".to_string(), + )); + } + + Ok(Self { + client: connection_manager, + key_prefix, + }) + } + + /// Generate key for api key data: apikey:{api_key_id} + fn api_key_key(&self, api_key_id: &str) -> String { + format!("{}:{}:{}", self.key_prefix, API_KEY_PREFIX, api_key_id) + } + + /// Generate key for api key list: apikey_list (paginated list of api key IDs) + fn api_key_list_key(&self) -> String { + format!("{}:{}", self.key_prefix, API_KEY_LIST_KEY) + } + + async fn get_by_ids( + &self, + ids: &[String], + ) -> Result, RepositoryError> { + if ids.is_empty() { + debug!("No api key IDs provided for batch fetch"); + return Ok(BatchRetrievalResult { + results: vec![], + failed_ids: vec![], + }); + } + + let mut conn = self.client.as_ref().clone(); + let keys: Vec = ids.iter().map(|id| self.api_key_key(id)).collect(); + + let values: Vec> = conn + .mget(&keys) + .await + .map_err(|e| self.map_redis_error(e, "batch_fetch_api_keys"))?; + + let mut apikeys = Vec::new(); + let mut failed_count = 0; + let mut failed_ids = Vec::new(); + for (i, value) in values.into_iter().enumerate() { + match value { + Some(json) => match self.deserialize_entity(&json, &ids[i], "apikey") { + Ok(apikey) => apikeys.push(apikey), + Err(e) => { + failed_count += 1; + error!("Failed to deserialize api key {}: {}", ids[i], e); + failed_ids.push(ids[i].clone()); + } + }, + None => { + warn!("Plugin {} not found in batch fetch", ids[i]); + } + } + } + + if failed_count > 0 { + warn!( + "Failed to deserialize {} out of {} api keys in batch", + failed_count, + ids.len() + ); + } + + Ok(BatchRetrievalResult { + results: apikeys, + failed_ids, + }) + } +} + +impl fmt::Debug for RedisApiKeyRepository { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "RedisApiKeyRepository {{ key_prefix: {} }}", + self.key_prefix + ) + } +} + +#[async_trait] +impl ApiKeyRepositoryTrait for RedisApiKeyRepository { + async fn create(&self, entity: ApiKeyRepoModel) -> Result { + if entity.id.is_empty() { + return Err(RepositoryError::InvalidData( + "API Key ID cannot be empty".to_string(), + )); + } + + let key = self.api_key_key(&entity.id); + let list_key = self.api_key_list_key(); + let json = self.serialize_entity(&entity, |a| &a.id, "apikey")?; + + let mut conn = self.client.as_ref().clone(); + + let existing: Option = conn + .get(&key) + .await + .map_err(|e| self.map_redis_error(e, "create_api_key_check"))?; + + if existing.is_some() { + return Err(RepositoryError::ConstraintViolation(format!( + "API Key with ID {} already exists", + entity.id + ))); + } + + // Use atomic pipeline for consistency + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.set(&key, json); + pipe.sadd(&list_key, &entity.id); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "create_api_key"))?; + + debug!("Successfully created API Key {}", entity.id); + Ok(entity) + } + + async fn list_paginated( + &self, + query: PaginationQuery, + ) -> Result, RepositoryError> { + if query.page == 0 { + return Err(RepositoryError::InvalidData( + "Page number must be greater than 0".to_string(), + )); + } + + if query.per_page == 0 { + return Err(RepositoryError::InvalidData( + "Per page count must be greater than 0".to_string(), + )); + } + let mut conn = self.client.as_ref().clone(); + let api_key_list_key = self.api_key_list_key(); + + // Get total count + let total: u64 = conn + .scard(&api_key_list_key) + .await + .map_err(|e| self.map_redis_error(e, "list_paginated_count"))?; + + if total == 0 { + return Ok(PaginatedResult { + items: vec![], + total: 0, + page: query.page, + per_page: query.per_page, + }); + } + + // Get all IDs and paginate in memory + let all_ids: Vec = conn + .smembers(&api_key_list_key) + .await + .map_err(|e| self.map_redis_error(e, "list_paginated_members"))?; + + let start = ((query.page - 1) * query.per_page) as usize; + let end = (start + query.per_page as usize).min(all_ids.len()); + + let ids_to_query = &all_ids[start..end]; + let items = self.get_by_ids(ids_to_query).await?; + + Ok(PaginatedResult { + items: items.results.clone(), + total, + page: query.page, + per_page: query.per_page, + }) + } + + async fn get_by_id(&self, id: &str) -> Result, RepositoryError> { + if id.is_empty() { + return Err(RepositoryError::InvalidData( + "API Key ID cannot be empty".to_string(), + )); + } + + let mut conn = self.client.as_ref().clone(); + let api_key_key = self.api_key_key(id); + + debug!("Fetching api key with ID: {}", id); + + let json: Option = conn + .get(&api_key_key) + .await + .map_err(|e| self.map_redis_error(e, "get_api_key_by_id"))?; + + match json { + Some(json) => { + debug!("Found api key with ID: {}", id); + self.deserialize_entity(&json, id, "apikey") + } + None => { + debug!("Api key with ID {} not found", id); + Ok(None) + } + } + } + + async fn list_permissions(&self, api_key_id: &str) -> Result, RepositoryError> { + let api_key = self.get_by_id(api_key_id).await?; + match api_key { + Some(api_key) => Ok(api_key.permissions), + None => Err(RepositoryError::NotFound(format!( + "Api key with ID {} not found", + api_key_id + ))), + } + } + + async fn delete_by_id(&self, id: &str) -> Result<(), RepositoryError> { + if id.is_empty() { + return Err(RepositoryError::InvalidData( + "API Key ID cannot be empty".to_string(), + )); + } + + let key = self.api_key_key(id); + let api_key_list_key = self.api_key_list_key(); + let mut conn = self.client.as_ref().clone(); + + debug!("Deleting api key with ID: {}", id); + + // Check if api key exists + let existing: Option = conn + .get(&key) + .await + .map_err(|e| self.map_redis_error(e, "delete_api_key_check"))?; + + if existing.is_none() { + return Err(RepositoryError::NotFound(format!( + "Api key with ID {} not found", + id + ))); + } + + // Use atomic pipeline to ensure consistency + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.del(&key); + pipe.srem(&api_key_list_key, id); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "delete_api_key"))?; + + debug!("Successfully deleted api key {}", id); + Ok(()) + } + + async fn count(&self) -> Result { + let mut conn = self.client.as_ref().clone(); + let api_key_list_key = self.api_key_list_key(); + + let count: u64 = conn + .scard(&api_key_list_key) + .await + .map_err(|e| self.map_redis_error(e, "count_api_keys"))?; + + Ok(count as usize) + } + + async fn has_entries(&self) -> Result { + let mut conn = self.client.as_ref().clone(); + let plugin_list_key = self.api_key_list_key(); + + debug!("Checking if plugin entries exist"); + + let exists: bool = conn + .exists(&plugin_list_key) + .await + .map_err(|e| self.map_redis_error(e, "has_entries_check"))?; + + debug!("Plugin entries exist: {}", exists); + Ok(exists) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let plugin_list_key = self.api_key_list_key(); + + debug!("Dropping all plugin entries"); + + // Get all plugin IDs first + let plugin_ids: Vec = conn + .smembers(&plugin_list_key) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_ids"))?; + + if plugin_ids.is_empty() { + debug!("No plugin entries to drop"); + return Ok(()); + } + + // Use pipeline for atomic operations + let mut pipe = redis::pipe(); + pipe.atomic(); + + // Delete all individual plugin entries + for plugin_id in &plugin_ids { + let plugin_key = self.api_key_key(plugin_id); + pipe.del(&plugin_key); + } + + // Delete the plugin list key + pipe.del(&plugin_list_key); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?; + + debug!("Dropped {} plugin entries", plugin_ids.len()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::models::SecretString; + + use super::*; + use chrono::Utc; + + fn create_test_api_key(id: &str) -> ApiKeyRepoModel { + ApiKeyRepoModel { + id: id.to_string(), + value: SecretString::new("test-value"), + name: "test-name".to_string(), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + } + } + + async fn setup_test_repo() -> RedisApiKeyRepository { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/".to_string()); + let client = redis::Client::open(redis_url).expect("Failed to create Redis client"); + let mut connection_manager = ConnectionManager::new(client) + .await + .expect("Failed to create Redis connection manager"); + + // Clear the api key list + connection_manager + .del::<&str, ()>("test_api_key:apikey_list") + .await + .unwrap(); + + RedisApiKeyRepository::new(Arc::new(connection_manager), "test_api_key".to_string()) + .expect("Failed to create Redis api key repository") + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_new_repository_creation() { + let repo = setup_test_repo().await; + assert_eq!(repo.key_prefix, "test_api_key"); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_new_repository_empty_prefix_fails() { + let client = + redis::Client::open("redis://127.0.0.1:6379/").expect("Failed to create Redis client"); + let connection_manager = redis::aio::ConnectionManager::new(client) + .await + .expect("Failed to create Redis connection manager"); + + let result = RedisApiKeyRepository::new(Arc::new(connection_manager), "".to_string()); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("key prefix cannot be empty")); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_key_generation() { + let repo = setup_test_repo().await; + + let api_key_key = repo.api_key_key("test-api-key"); + assert_eq!(api_key_key, "test_api_key:apikey:test-api-key"); + + let list_key = repo.api_key_list_key(); + assert_eq!(list_key, "test_api_key:apikey_list"); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_serialize_deserialize_api_key() { + let repo = setup_test_repo().await; + let api_key = create_test_api_key("test-api-key"); + + let json = repo + .serialize_entity(&api_key, |a| &a.id, "apikey") + .unwrap(); + let deserialized: ApiKeyRepoModel = repo + .deserialize_entity(&json, &api_key.id, "apikey") + .unwrap(); + + assert_eq!(api_key.id, deserialized.id); + assert_eq!(api_key.value, deserialized.value); + assert_eq!(api_key.name, deserialized.name); + assert_eq!(api_key.allowed_origins, deserialized.allowed_origins); + assert_eq!(api_key.permissions, deserialized.permissions); + assert_eq!(api_key.created_at, deserialized.created_at); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_create_api_key() { + let repo = setup_test_repo().await; + let api_key_id = uuid::Uuid::new_v4().to_string(); + let api_key = create_test_api_key(&api_key_id); + + let result = repo.create(api_key.clone()).await; + assert!(result.is_ok()); + + let retrieved = repo.get_by_id(&api_key_id).await.unwrap(); + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, api_key.id); + assert_eq!(retrieved.value, api_key.value); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_get_nonexistent_api_key() { + let repo = setup_test_repo().await; + + let result = repo.get_by_id("nonexistent-api-key").await; + assert!(matches!(result, Ok(None))); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_error_handling_empty_id() { + let repo = setup_test_repo().await; + + let result = repo.get_by_id("").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("ID cannot be empty")); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_get_by_ids_api_keys() { + let repo = setup_test_repo().await; + let api_key_id1 = uuid::Uuid::new_v4().to_string(); + let api_key_id2 = uuid::Uuid::new_v4().to_string(); + let api_key1 = create_test_api_key(&api_key_id1); + let api_key2 = create_test_api_key(&api_key_id2); + + repo.create(api_key1.clone()).await.unwrap(); + repo.create(api_key2.clone()).await.unwrap(); + + let retrieved = repo + .get_by_ids(&[api_key1.id.clone(), api_key2.id.clone()]) + .await + .unwrap(); + assert!(retrieved.results.len() == 2); + assert_eq!(retrieved.results[0].id, api_key1.id); + assert_eq!(retrieved.results[1].id, api_key2.id); + assert_eq!(retrieved.failed_ids.len(), 0); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_list_paginated_api_keys() { + let repo = setup_test_repo().await; + + let api_key_id1 = uuid::Uuid::new_v4().to_string(); + let api_key_id2 = uuid::Uuid::new_v4().to_string(); + let api_key_id3 = uuid::Uuid::new_v4().to_string(); + let api_key1 = create_test_api_key(&api_key_id1); + let api_key2 = create_test_api_key(&api_key_id2); + let api_key3 = create_test_api_key(&api_key_id3); + + repo.create(api_key1.clone()).await.unwrap(); + repo.create(api_key2.clone()).await.unwrap(); + repo.create(api_key3.clone()).await.unwrap(); + + let query = PaginationQuery { + page: 1, + per_page: 2, + }; + + let result = repo.list_paginated(query).await; + assert!(result.is_ok()); + let result = result.unwrap(); + println!("result: {:?}", result); + assert!(result.items.len() == 2); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries() { + let repo = setup_test_repo().await; + assert!(!repo.has_entries().await.unwrap()); + repo.create(create_test_api_key("test-api-key")) + .await + .unwrap(); + assert!(repo.has_entries().await.unwrap()); + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } +} diff --git a/src/repositories/api_key/mod.rs b/src/repositories/api_key/mod.rs new file mode 100644 index 000000000..492c1c70e --- /dev/null +++ b/src/repositories/api_key/mod.rs @@ -0,0 +1,467 @@ +//! API Key Repository Module +//! +//! This module provides the api key repository for the OpenZeppelin Relayer service. +//! It implements a specialized repository pattern for managing api key configurations, +//! supporting both in-memory and Redis-backed storage implementations. +//! +//! ## Repository Implementations +//! +//! - [`InMemoryApiKeyRepository`]: In-memory storage for testing/development +//! - [`RedisApiKeyRepository`]: Redis-backed storage for production environments +//! +//! ## API Keys +//! +//! The api key system allows extending relayer authorization scheme through api keys. +//! Each api key is identified by a unique ID and contains a list of permissions that +//! restrict the api key's access to the server. +//! +use async_trait::async_trait; +use redis::aio::ConnectionManager; +use std::sync::Arc; + +pub mod api_key_in_memory; +pub mod api_key_redis; + +pub use api_key_in_memory::*; +pub use api_key_redis::*; + +#[cfg(test)] +use mockall::automock; + +use crate::{ + models::{ApiKeyRepoModel, PaginationQuery, RepositoryError}, + repositories::PaginatedResult, +}; + +#[async_trait] +#[allow(dead_code)] +#[cfg_attr(test, automock)] +pub trait ApiKeyRepositoryTrait { + async fn get_by_id(&self, id: &str) -> Result, RepositoryError>; + async fn create(&self, api_key: ApiKeyRepoModel) -> Result; + async fn list_paginated( + &self, + query: PaginationQuery, + ) -> Result, RepositoryError>; + async fn count(&self) -> Result; + async fn list_permissions(&self, api_key_id: &str) -> Result, RepositoryError>; + async fn delete_by_id(&self, api_key_id: &str) -> Result<(), RepositoryError>; + async fn has_entries(&self) -> Result; + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; +} + +/// Enum wrapper for different plugin repository implementations +#[derive(Debug, Clone)] +pub enum ApiKeyRepositoryStorage { + InMemory(InMemoryApiKeyRepository), + Redis(RedisApiKeyRepository), +} + +impl ApiKeyRepositoryStorage { + pub fn new_in_memory() -> Self { + Self::InMemory(InMemoryApiKeyRepository::new()) + } + + pub fn new_redis( + connection_manager: Arc, + key_prefix: String, + ) -> Result { + let redis_repo = RedisApiKeyRepository::new(connection_manager, key_prefix)?; + Ok(Self::Redis(redis_repo)) + } +} + +#[async_trait] +impl ApiKeyRepositoryTrait for ApiKeyRepositoryStorage { + async fn get_by_id(&self, id: &str) -> Result, RepositoryError> { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.get_by_id(id).await, + ApiKeyRepositoryStorage::Redis(repo) => repo.get_by_id(id).await, + } + } + + async fn create(&self, api_key: ApiKeyRepoModel) -> Result { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.create(api_key).await, + ApiKeyRepositoryStorage::Redis(repo) => repo.create(api_key).await, + } + } + + async fn list_permissions(&self, api_key_id: &str) -> Result, RepositoryError> { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.list_permissions(api_key_id).await, + ApiKeyRepositoryStorage::Redis(repo) => repo.list_permissions(api_key_id).await, + } + } + + async fn delete_by_id(&self, api_key_id: &str) -> Result<(), RepositoryError> { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.delete_by_id(api_key_id).await, + ApiKeyRepositoryStorage::Redis(repo) => repo.delete_by_id(api_key_id).await, + } + } + + async fn list_paginated( + &self, + query: PaginationQuery, + ) -> Result, RepositoryError> { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.list_paginated(query).await, + ApiKeyRepositoryStorage::Redis(repo) => repo.list_paginated(query).await, + } + } + + async fn count(&self) -> Result { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.count().await, + ApiKeyRepositoryStorage::Redis(repo) => repo.count().await, + } + } + + async fn has_entries(&self) -> Result { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.has_entries().await, + ApiKeyRepositoryStorage::Redis(repo) => repo.has_entries().await, + } + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + match self { + ApiKeyRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await, + ApiKeyRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, + } + } +} + +impl PartialEq for ApiKeyRepoModel { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.name == other.name + && self.allowed_origins == other.allowed_origins + && self.permissions == other.permissions + } +} + +#[cfg(test)] +mod tests { + use crate::models::SecretString; + + use super::*; + + use chrono::Utc; + + // Helper function to create a test api key + fn create_test_api_key( + id: &str, + name: &str, + value: &str, + allowed_origins: &[&str], + permissions: &[&str], + ) -> ApiKeyRepoModel { + ApiKeyRepoModel { + id: id.to_string(), + name: name.to_string(), + value: SecretString::new(value), + allowed_origins: allowed_origins.iter().map(|s| s.to_string()).collect(), + permissions: permissions.iter().map(|s| s.to_string()).collect(), + created_at: Utc::now().to_string(), + } + } + + #[tokio::test] + async fn test_api_key_repository_storage_get_by_id_existing() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + let api_key = create_test_api_key( + "test-api-key", + "test-name", + "test-value", + &["*"], + &["relayer:all:execute"], + ); + + // Add the api key first + storage.create(api_key.clone()).await.unwrap(); + + // Get the api key + let result = storage.get_by_id("test-api-key").await.unwrap(); + assert_eq!(result, Some(api_key)); + } + + #[tokio::test] + async fn test_api_key_repository_storage_get_by_id_non_existing() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + let result = storage.get_by_id("non-existent").await.unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn test_api_key_repository_storage_add_success() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + let api_key = create_test_api_key( + "test-api-key", + "test-name", + "test-value", + &["*"], + &["relayer:all:execute"], + ); + + let result = storage.create(api_key).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_api_key_repository_storage_add_duplicate() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + let api_key = create_test_api_key( + "test-api-key", + "test-name", + "test-value", + &["*"], + &["relayer:all:execute"], + ); + + // Add the api key first time + storage.create(api_key.clone()).await.unwrap(); + + // Try to add the same api key again - should succeed (overwrite) + let result = storage.create(api_key).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_api_key_repository_storage_count_empty() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + let count = storage.count().await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_api_key_repository_storage_count_with_api_keys() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + // Add multiple plugins + storage + .create(create_test_api_key( + "api-key1", + "test-name1", + "test-value1", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + storage + .create(create_test_api_key( + "api-key2", + "test-name2", + "test-value2", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + storage + .create(create_test_api_key( + "api-key3", + "test-name3", + "test-value3", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + + let count = storage.count().await.unwrap(); + assert_eq!(count, 3); + } + + #[tokio::test] + async fn test_api_key_repository_storage_has_entries_empty() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + let has_entries = storage.has_entries().await.unwrap(); + assert!(!has_entries); + } + + #[tokio::test] + async fn test_api_key_repository_storage_has_entries_with_api_keys() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + storage + .create(create_test_api_key( + "api-key1", + "test-name1", + "test-value1", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + + let has_entries = storage.has_entries().await.unwrap(); + assert!(has_entries); + } + + #[tokio::test] + async fn test_api_key_repository_storage_drop_all_entries_empty() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + let result = storage.drop_all_entries().await; + assert!(result.is_ok()); + + let count = storage.count().await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_api_key_repository_storage_drop_all_entries_with_api_keys() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + // Add multiple plugins + storage + .create(create_test_api_key( + "api-key1", + "test-name1", + "test-value1", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + storage + .create(create_test_api_key( + "api-key2", + "test-name2", + "test-value2", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + + let result = storage.drop_all_entries().await; + assert!(result.is_ok()); + + let count = storage.count().await.unwrap(); + assert_eq!(count, 0); + + let has_entries = storage.has_entries().await.unwrap(); + assert!(!has_entries); + } + + #[tokio::test] + async fn test_api_key_repository_storage_list_paginated_empty() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 0); + assert_eq!(result.total, 0); + assert_eq!(result.page, 1); + assert_eq!(result.per_page, 10); + } + + #[tokio::test] + async fn test_api_key_repository_storage_list_paginated_with_api_keys() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + // Add multiple plugins + storage + .create(create_test_api_key( + "api-key1", + "test-name1", + "test-value1", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + storage + .create(create_test_api_key( + "api-key2", + "test-name2", + "test-value2", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + storage + .create(create_test_api_key( + "api-key3", + "test-name3", + "test-value3", + &["*"], + &["relayer:all:execute"], + )) + .await + .unwrap(); + + let query = PaginationQuery { + page: 1, + per_page: 2, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 2); + assert_eq!(result.total, 3); + assert_eq!(result.page, 1); + assert_eq!(result.per_page, 2); + } + + #[tokio::test] + async fn test_api_key_repository_storage_workflow() { + let storage = ApiKeyRepositoryStorage::new_in_memory(); + + // Initially empty + assert!(!storage.has_entries().await.unwrap()); + assert_eq!(storage.count().await.unwrap(), 0); + + // Add plugins + let api_key1 = create_test_api_key( + "api-key1", + "test-name1", + "test-value1", + &["*"], + &["relayer:all:execute"], + ); + let api_key2 = create_test_api_key( + "api-key2", + "test-name2", + "test-value2", + &["*"], + &["relayer:all:execute"], + ); + + storage.create(api_key1.clone()).await.unwrap(); + storage.create(api_key2.clone()).await.unwrap(); + + // Check state + assert!(storage.has_entries().await.unwrap()); + assert_eq!(storage.count().await.unwrap(), 2); + + // Retrieve specific plugin + let retrieved = storage.get_by_id("api-key1").await.unwrap(); + assert_eq!(retrieved, Some(api_key1)); + + // List all plugins + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let result = storage.list_paginated(query).await.unwrap(); + assert_eq!(result.items.len(), 2); + assert_eq!(result.total, 2); + + // Clear all plugins + storage.drop_all_entries().await.unwrap(); + assert!(!storage.has_entries().await.unwrap()); + assert_eq!(storage.count().await.unwrap(), 0); + } +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index 77f4a2bd5..b8638774e 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -29,6 +29,9 @@ pub use network::*; mod plugin; pub use plugin::*; +pub mod api_key; +pub use api_key::*; + // Redis base utilities for shared functionality pub mod redis_base; diff --git a/src/services/plugins/mod.rs b/src/services/plugins/mod.rs index 49ae8ec61..deb6b3e98 100644 --- a/src/services/plugins/mod.rs +++ b/src/services/plugins/mod.rs @@ -10,8 +10,8 @@ use crate::{ RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, }; use actix_web::web; @@ -88,11 +88,11 @@ impl PluginService { } #[allow(clippy::type_complexity)] - async fn call_plugin( + async fn call_plugin( &self, plugin: PluginModel, plugin_call_request: PluginCallRequest, - state: Arc>, + state: Arc>, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -107,6 +107,7 @@ impl PluginService { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let socket_path = format!("/tmp/{}.sock", Uuid::new_v4()); let script_path = Self::resolve_plugin_path(&plugin.path); @@ -141,7 +142,7 @@ impl PluginService { #[async_trait] #[cfg_attr(test, automock)] -pub trait PluginServiceTrait: Send + Sync +pub trait PluginServiceTrait: Send + Sync where J: JobProducerTrait + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, @@ -151,18 +152,19 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { fn new(runner: PluginRunner) -> Self; async fn call_plugin( &self, plugin: PluginModel, plugin_call_request: PluginCallRequest, - state: Arc>>, + state: Arc>>, ) -> Result; } #[async_trait] -impl PluginServiceTrait +impl PluginServiceTrait for PluginService where J: JobProducerTrait + 'static, @@ -173,6 +175,7 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { fn new(runner: PluginRunner) -> Self { Self::new(runner) @@ -182,7 +185,7 @@ where &self, plugin: PluginModel, plugin_call_request: PluginCallRequest, - state: Arc>>, + state: Arc>>, ) -> Result { self.call_plugin(plugin, plugin_call_request, state).await } @@ -197,9 +200,9 @@ mod tests { jobs::MockJobProducerTrait, models::PluginModel, repositories::{ - NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, - RelayerRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, - TransactionRepositoryStorage, + ApiKeyRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, + PluginRepositoryStorage, RelayerRepositoryStorage, SignerRepositoryStorage, + TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, utils::mocks::mockutils::create_mock_app_state, }; @@ -231,21 +234,13 @@ mod tests { path: "test-path".to_string(), timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), }; - let app_state: AppState< - MockJobProducerTrait, - RelayerRepositoryStorage, - TransactionRepositoryStorage, - NetworkRepositoryStorage, - NotificationRepositoryStorage, - SignerRepositoryStorage, - TransactionCounterRepositoryStorage, - PluginRepositoryStorage, - > = create_mock_app_state(None, None, None, Some(vec![plugin.clone()]), None).await; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin.clone()]), None).await; let mut plugin_runner = MockPluginRunnerTrait::default(); plugin_runner - .expect_run::() + .expect_run::() .returning(|_, _, _, _, _, _, _| { Ok(ScriptResult { logs: vec![LogEntry { diff --git a/src/services/plugins/relayer_api.rs b/src/services/plugins/relayer_api.rs index b0c481cfa..b784001f8 100644 --- a/src/services/plugins/relayer_api.rs +++ b/src/services/plugins/relayer_api.rs @@ -15,7 +15,7 @@ use crate::models::{ }; use crate::observability::request_id::set_request_id; use crate::repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, TransactionCounterTrait, TransactionRepository, }; use crate::services::plugins::PluginError; @@ -68,7 +68,7 @@ pub struct Response { #[async_trait] #[cfg_attr(test, automock)] -pub trait RelayerApiTrait: Send + Sync +pub trait RelayerApiTrait: Send + Sync where J: JobProducerTrait + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, @@ -78,46 +78,47 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { async fn handle_request( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Response; async fn process_request( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Result; async fn handle_send_transaction( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Result; async fn handle_get_transaction( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Result; async fn handle_get_relayer_status( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Result; async fn handle_sign_transaction( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Result; async fn handle_get_relayer_info( &self, request: Request, - state: &web::ThinData>, + state: &web::ThinData>, ) -> Result; } @@ -126,10 +127,10 @@ pub struct RelayerApi; impl RelayerApi { #[instrument(name = "Plugin::handle_request", skip_all, fields(method = %request.method, relayer_id = %request.relayer_id, plugin_req_id = %request.request_id))] - pub async fn handle_request( + pub async fn handle_request( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Response where J: JobProducerTrait + 'static, @@ -144,6 +145,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // Restore original HTTP request id onto this span if provided if let Some(http_rid) = request.http_request_id.clone() { @@ -160,10 +162,10 @@ impl RelayerApi { } } - async fn process_request( + async fn process_request( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + 'static, @@ -178,6 +180,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { match request.method { PluginMethod::SendTransaction => self.handle_send_transaction(request, state).await, @@ -188,10 +191,10 @@ impl RelayerApi { } } - async fn handle_send_transaction( + async fn handle_send_transaction( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + 'static, @@ -206,6 +209,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer_repo_model = get_relayer_by_id(request.relayer_id.clone(), state) .await @@ -245,10 +249,10 @@ impl RelayerApi { }) } - async fn handle_get_transaction( + async fn handle_get_transaction( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + 'static, @@ -263,6 +267,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { // validation purpose only, checks if relayer exists get_relayer_by_id(request.relayer_id.clone(), state) @@ -289,10 +294,10 @@ impl RelayerApi { }) } - async fn handle_get_relayer_status( + async fn handle_get_relayer_status( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + 'static, @@ -307,6 +312,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let network_relayer = get_network_relayer(request.relayer_id.clone(), state) .await @@ -327,10 +333,10 @@ impl RelayerApi { }) } - async fn handle_sign_transaction( + async fn handle_sign_transaction( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + 'static, @@ -345,6 +351,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let sign_request: SignTransactionRequest = serde_json::from_value(request.payload) .map_err(|e| PluginError::InvalidPayload(e.to_string()))?; @@ -368,10 +375,10 @@ impl RelayerApi { }) } - async fn handle_get_relayer_info( + async fn handle_get_relayer_info( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result where J: JobProducerTrait + 'static, @@ -386,6 +393,7 @@ impl RelayerApi { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let relayer = get_relayer_by_id(request.relayer_id.clone(), state) .await @@ -402,7 +410,7 @@ impl RelayerApi { } #[async_trait] -impl RelayerApiTrait +impl RelayerApiTrait for RelayerApi where J: JobProducerTrait + 'static, @@ -413,11 +421,12 @@ where SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { async fn handle_request( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Response { self.handle_request(request, state).await } @@ -425,7 +434,7 @@ where async fn process_request( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result { self.process_request(request, state).await } @@ -433,7 +442,7 @@ where async fn handle_send_transaction( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result { self.handle_send_transaction(request, state).await } @@ -441,7 +450,7 @@ where async fn handle_get_transaction( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result { self.handle_get_transaction(request, state).await } @@ -449,7 +458,7 @@ where async fn handle_get_relayer_status( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result { self.handle_get_relayer_status(request, state).await } @@ -457,7 +466,7 @@ where async fn handle_sign_transaction( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result { self.handle_sign_transaction(request, state).await } @@ -465,7 +474,7 @@ where async fn handle_get_relayer_info( &self, request: Request, - state: &ThinDataAppState, + state: &ThinDataAppState, ) -> Result { self.handle_get_relayer_info(request, state).await } @@ -492,6 +501,7 @@ mod tests { async fn test_handle_request() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -522,6 +532,7 @@ mod tests { setup_test_env(); let paused = true; let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), paused)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -552,6 +563,7 @@ mod tests { async fn test_handle_request_using_trait() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -592,6 +604,7 @@ mod tests { async fn test_handle_get_transaction() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -623,6 +636,7 @@ mod tests { async fn test_handle_get_transaction_error_relayer_not_found() { setup_test_env(); let state = create_mock_app_state( + None, None, Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -655,6 +669,7 @@ mod tests { async fn test_handle_get_transaction_error_transaction_not_found() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -687,6 +702,7 @@ mod tests { async fn test_handle_get_relayer_status_relayer_not_found() { setup_test_env(); let state = create_mock_app_state( + None, None, Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -717,6 +733,7 @@ mod tests { async fn test_handle_sign_transaction_evm_not_supported() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -749,6 +766,7 @@ mod tests { async fn test_handle_sign_transaction_invalid_payload() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -779,6 +797,7 @@ mod tests { async fn test_handle_sign_transaction_relayer_not_found() { setup_test_env(); let state = create_mock_app_state( + None, None, Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -811,6 +830,7 @@ mod tests { async fn test_handle_get_relayer_info_success() { setup_test_env(); let state = create_mock_app_state( + None, Some(vec![create_mock_relayer("test".to_string(), false)]), Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), @@ -846,6 +866,7 @@ mod tests { async fn test_handle_get_relayer_info_relayer_not_found() { setup_test_env(); let state = create_mock_app_state( + None, None, Some(vec![create_mock_signer()]), Some(vec![create_mock_network()]), diff --git a/src/services/plugins/runner.rs b/src/services/plugins/runner.rs index 163823431..878a98fcb 100644 --- a/src/services/plugins/runner.rs +++ b/src/services/plugins/runner.rs @@ -16,8 +16,8 @@ use crate::{ ThinDataAppState, TransactionRepoModel, }, repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, }, }; @@ -32,7 +32,7 @@ use mockall::automock; #[async_trait] pub trait PluginRunnerTrait { #[allow(clippy::type_complexity, clippy::too_many_arguments)] - async fn run( + async fn run( &self, plugin_id: String, socket_path: &str, @@ -40,7 +40,7 @@ pub trait PluginRunnerTrait { timeout_duration: Duration, script_params: String, http_request_id: Option, - state: Arc>, + state: Arc>, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -54,7 +54,8 @@ pub trait PluginRunnerTrait { NFR: Repository + Send + Sync + 'static, SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, - PR: PluginRepositoryTrait + Send + Sync + 'static; + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static; } #[derive(Default)] @@ -62,8 +63,7 @@ pub struct PluginRunner; #[async_trait] impl PluginRunnerTrait for PluginRunner { - #[allow(clippy::too_many_arguments)] - async fn run( + async fn run( &self, plugin_id: String, socket_path: &str, @@ -71,7 +71,7 @@ impl PluginRunnerTrait for PluginRunner { timeout_duration: Duration, script_params: String, http_request_id: Option, - state: Arc>, + state: Arc>, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -86,6 +86,7 @@ impl PluginRunnerTrait for PluginRunner { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let socket_service = SocketService::new(socket_path)?; let socket_path_clone = socket_service.socket_path().to_string(); @@ -144,9 +145,9 @@ mod tests { use crate::{ jobs::MockJobProducerTrait, repositories::{ - NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, - RelayerRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, - TransactionRepositoryStorage, + ApiKeyRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, + PluginRepositoryStorage, RelayerRepositoryStorage, SignerRepositoryStorage, + TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, services::plugins::LogLevel, utils::mocks::mockutils::create_mock_app_state, @@ -185,14 +186,14 @@ mod tests { fs::write(script_path.clone(), content).unwrap(); fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); - let state = create_mock_app_state(None, None, None, None, None).await; + let state = create_mock_app_state(None, None, None, None, None, None).await; let plugin_runner = PluginRunner; let plugin_id = "test-plugin".to_string(); let socket_path_str = socket_path.display().to_string(); let script_path_str = script_path.display().to_string(); let result = plugin_runner - .run::( + .run::( plugin_id, &socket_path_str, script_path_str, @@ -236,7 +237,7 @@ mod tests { fs::write(script_path.clone(), content).unwrap(); fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); - let state = create_mock_app_state(None, None, None, None, None).await; + let state = create_mock_app_state(None, None, None, None, None, None).await; let plugin_runner = PluginRunner; // Use 100ms timeout for a 200ms script @@ -244,8 +245,8 @@ mod tests { let socket_path_str = socket_path.display().to_string(); let script_path_str = script_path.display().to_string(); let result = plugin_runner - .run::( - plugin_id, + .run::( + plugin_id, &socket_path_str, script_path_str, Duration::from_millis(100), // 100ms timeout diff --git a/src/services/plugins/script_executor.rs b/src/services/plugins/script_executor.rs index a9a5e57aa..d65166efe 100644 --- a/src/services/plugins/script_executor.rs +++ b/src/services/plugins/script_executor.rs @@ -234,6 +234,7 @@ mod tests { assert!(result.is_ok()); let result = result.unwrap(); + // TypeScript compilation errors are now returned in the error field assert!(!result.error.is_empty()); assert!(result.error.contains("Plugin executor failed")); diff --git a/src/services/plugins/socket.rs b/src/services/plugins/socket.rs index 1088bcad5..8b31bde95 100644 --- a/src/services/plugins/socket.rs +++ b/src/services/plugins/socket.rs @@ -54,7 +54,7 @@ use crate::models::{ TransactionRepoModel, }; use crate::repositories::{ - NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, TransactionCounterTrait, TransactionRepository, }; use std::sync::Arc; @@ -108,14 +108,14 @@ impl SocketService { /// /// A vector of traces. #[allow(clippy::type_complexity)] - pub async fn listen( + pub async fn listen( self, shutdown_rx: oneshot::Receiver<()>, - state: Arc>, + state: Arc>, relayer_api: Arc, ) -> Result, PluginError> where - RA: RelayerApiTrait + 'static + Send + Sync, + RA: RelayerApiTrait + 'static + Send + Sync, J: JobProducerTrait + Send + Sync + 'static, RR: RelayerRepository + Repository + Send + Sync + 'static, TR: TransactionRepository @@ -128,6 +128,7 @@ impl SocketService { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let mut shutdown = shutdown_rx; @@ -138,7 +139,7 @@ impl SocketService { let relayer_api = Arc::clone(&relayer_api); tokio::select! { Ok((stream, _)) = self.listener.accept() => { - let result = tokio::spawn(Self::handle_connection::(stream, state, relayer_api)) + let result = tokio::spawn(Self::handle_connection::(stream, state, relayer_api)) .await .map_err(|e| PluginError::SocketError(e.to_string()))?; @@ -169,13 +170,13 @@ impl SocketService { /// /// A vector of traces. #[allow(clippy::type_complexity)] - async fn handle_connection( + async fn handle_connection( stream: UnixStream, - state: Arc>, + state: Arc>, relayer_api: Arc, ) -> Result, PluginError> where - RA: RelayerApiTrait + 'static + Send + Sync, + RA: RelayerApiTrait + 'static + Send + Sync, J: JobProducerTrait + 'static, RR: RelayerRepository + Repository + Send + Sync + 'static, TR: TransactionRepository @@ -188,6 +189,7 @@ impl SocketService { SR: Repository + Send + Sync + 'static, TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { let (r, mut w) = stream.into_split(); let mut reader = BufReader::new(r).lines(); @@ -240,7 +242,7 @@ mod tests { let service = SocketService::new(socket_path.to_str().unwrap()).unwrap(); - let state = create_mock_app_state(None, None, None, None, None).await; + let state = create_mock_app_state(None, None, None, None, None, None).await; let (shutdown_tx, shutdown_rx) = oneshot::channel(); let listen_handle = tokio::spawn(async move { @@ -279,7 +281,7 @@ mod tests { let service = SocketService::new(socket_path.to_str().unwrap()).unwrap(); - let state = create_mock_app_state(None, None, None, None, None).await; + let state = create_mock_app_state(None, None, None, None, None, None).await; let (shutdown_tx, shutdown_rx) = oneshot::channel(); let listen_handle = tokio::spawn(async move { diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 10472a5c1..91ca3cbd7 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -13,15 +13,17 @@ pub mod mockutils { }, jobs::MockJobProducerTrait, models::{ - AppState, EvmTransactionData, EvmTransactionRequest, LocalSignerConfigStorage, - NetworkConfigData, NetworkRepoModel, NetworkTransactionData, NetworkType, - NotificationRepoModel, PluginModel, RelayerEvmPolicy, RelayerNetworkPolicy, - RelayerRepoModel, RelayerSolanaPolicy, SecretString, SignerConfigStorage, - SignerRepoModel, SolanaTransactionData, TransactionRepoModel, TransactionStatus, + ApiKeyRepoModel, AppState, EvmTransactionData, EvmTransactionRequest, + LocalSignerConfigStorage, NetworkConfigData, NetworkRepoModel, NetworkTransactionData, + NetworkType, NotificationRepoModel, PluginModel, RelayerEvmPolicy, + RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, SecretString, + SignerConfigStorage, SignerRepoModel, SolanaTransactionData, TransactionRepoModel, + TransactionStatus, }, repositories::{ - NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, - PluginRepositoryTrait, RelayerRepositoryStorage, Repository, SignerRepositoryStorage, + ApiKeyRepositoryStorage, ApiKeyRepositoryTrait, NetworkRepositoryStorage, + NotificationRepositoryStorage, PluginRepositoryStorage, PluginRepositoryTrait, + RelayerRepositoryStorage, Repository, SignerRepositoryStorage, TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, }; @@ -173,6 +175,7 @@ pub mod mockutils { } pub async fn create_mock_app_state( + api_keys: Option>, relayers: Option>, signers: Option>, networks: Option>, @@ -187,6 +190,7 @@ pub mod mockutils { SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, + ApiKeyRepositoryStorage, > { let relayer_repository = Arc::new(RelayerRepositoryStorage::new_in_memory()); if let Some(relayers) = relayers { @@ -223,6 +227,13 @@ pub mod mockutils { } } + let api_key_repository = Arc::new(ApiKeyRepositoryStorage::new_in_memory()); + if let Some(api_keys) = api_keys { + for api_key in api_keys { + api_key_repository.create(api_key).await.unwrap(); + } + } + let mut mock_job_producer = MockJobProducerTrait::new(); mock_job_producer @@ -256,6 +267,7 @@ pub mod mockutils { ), job_producer: Arc::new(mock_job_producer), plugin_repository, + api_key_repository, } } @@ -273,6 +285,17 @@ pub mod mockutils { } } + pub fn create_mock_api_key() -> ApiKeyRepoModel { + ApiKeyRepoModel { + id: "test-api-key".to_string(), + name: "test-name".to_string(), + value: SecretString::new("test-value"), + allowed_origins: vec!["*".to_string()], + permissions: vec!["relayer:all:execute".to_string()], + created_at: Utc::now().to_string(), + } + } + pub fn create_test_server_config(storage_type: RepositoryStorageType) -> ServerConfig { ServerConfig { host: "localhost".to_string(),