#![allow(clippy::literal_string_with_formatting_args)]
use std::sync::Arc;
use axum::{
Router,
extract::Request,
middleware::from_fn_with_state,
routing::{delete, get, patch, post},
};
use axum_extra::middleware::option_layer;
use prometheus::Registry;
use tower_http::{
LatencyUnit,
trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer},
};
use tracing::Level;
use utoipa_swagger_ui::SwaggerUi;
use super::{
backend_service::BackendService,
handlers::{create_handler, delete_handler, get_handler, update_handler, validate_handler},
metrics::{HttpMetrics, metrics_handler, metrics_middleware},
openapi::get_openapi_json,
};
use crate::{
custom_domains::base::traits::{repository::Repository, validation::ValidatesDomains},
http::middleware::rate_limiter::layer_by_ip,
reqwest::StatusCode,
};
#[derive(Clone, Debug, Default)]
pub struct RateLimitConfig {
pub limit_get: Option<u32>,
pub limit_create: Option<u32>,
pub limit_patch: Option<u32>,
pub limit_delete: Option<u32>,
pub limit_validate: Option<u32>,
}
pub fn create_router(
repository: Arc<dyn Repository>,
validator: Arc<dyn ValidatesDomains>,
metrics_registry: Registry,
rate_limits: RateLimitConfig,
with_metrics_endpoint: bool,
bypass_token: Option<String>,
) -> Router {
let backend_service = BackendService::new(repository, validator);
let response = (StatusCode::TOO_MANY_REQUESTS, "Too many requests");
let create_rate_limiter = |limit: Option<u32>, response| {
option_layer(
limit.map(|lim| layer_by_ip(lim, 2 * lim, response, bypass_token.clone()).unwrap()),
)
};
let rl_get = create_rate_limiter(rate_limits.limit_get, response);
let rl_create = create_rate_limiter(rate_limits.limit_create, response);
let rl_patch = create_rate_limiter(rate_limits.limit_patch, response);
let rl_delete = create_rate_limiter(rate_limits.limit_delete, response);
let rl_validate = create_rate_limiter(rate_limits.limit_validate, response);
let api_router = Router::new()
.route("/v1/{:id}", post(create_handler).layer(rl_create))
.route("/v1/{:id}", get(get_handler).layer(rl_get))
.route("/v1/{:id}", patch(update_handler).layer(rl_patch))
.route("/v1/{:id}", delete(delete_handler).layer(rl_delete))
.route(
"/v1/{:id}/validate",
get(validate_handler).layer(rl_validate),
)
.layer(from_fn_with_state(
Arc::new(HttpMetrics::new(metrics_registry.clone())),
metrics_middleware,
))
.layer(
TraceLayer::new_for_http()
.make_span_with(|req: &Request| {
tracing::info_span!(
"custom_domains",
method = %req.method(),
url = %req.uri(),
http = ?req.version(),
)
})
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(
DefaultOnResponse::new()
.level(Level::INFO)
.latency_unit(LatencyUnit::Seconds),
),
)
.with_state(backend_service);
let metrics_router = if with_metrics_endpoint {
Router::new()
.route("/metrics", get(metrics_handler))
.with_state(metrics_registry)
} else {
Router::new()
};
let swagger_ui =
SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", get_openapi_json());
api_router.merge(metrics_router).merge(swagger_ui)
}
#[cfg(test)]
mod tests {
use std::{str::FromStr, sync::Arc};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
};
use candid::Principal;
use fqdn::FQDN;
use prometheus::Registry;
use serde_json::Value;
use tower::{Service, util::ServiceExt};
use crate::custom_domains::{
backend::router::{RateLimitConfig, create_router},
base::{
traits::{
repository::{MockRepository, RepositoryError},
validation::{MockValidatesDomains, ValidationError},
},
types::{
domain::{DomainStatus, RegistrationStatus},
task::{InputTask, TaskKind},
},
},
};
const BODY_LIMIT: usize = 5000;
fn create_test_router(
mock_repository: MockRepository,
mock_validator: MockValidatesDomains,
) -> axum::Router {
let registry = Registry::new_custom(Some("custom_domains".into()), None).unwrap();
create_router(
Arc::new(mock_repository),
Arc::new(mock_validator),
registry,
RateLimitConfig::default(),
true,
None,
)
}
fn create_test_router_with_rate_limiter(
mock_repository: MockRepository,
mock_validator: MockValidatesDomains,
rate_limits: RateLimitConfig,
) -> axum::Router {
let registry = Registry::new_custom(Some("custom_domains".into()), None).unwrap();
create_router(
Arc::new(mock_repository),
Arc::new(mock_validator),
registry,
rate_limits,
true,
None,
)
}
async fn post_domain_request(router: axum::Router, domain: &str) -> (StatusCode, Value) {
let request = Request::builder()
.method("POST")
.uri(format!("/v1/{domain}"))
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
let body_bytes = to_bytes(response.into_body(), BODY_LIMIT).await.unwrap();
let body_json: Value = serde_json::from_slice(&body_bytes).unwrap();
(status, body_json)
}
#[tokio::test]
async fn test_post_domain_success_accepted() {
let mut mock_validator = MockValidatesDomains::new();
let expected_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
mock_validator
.expect_validate()
.returning(move |_| Box::pin(async move { Ok(expected_canister_id) }));
let mut mock_repository = MockRepository::new();
let domain_normal = "example.org";
let subdomain_unicode = "тест.unicode.org";
let expected_task_normal = InputTask::new(
TaskKind::Issue,
FQDN::from_str(domain_normal).unwrap(),
false,
);
let expected_task_unicode = InputTask::new(
TaskKind::Issue,
FQDN::from_str(subdomain_unicode).unwrap(),
false,
);
mock_repository
.expect_try_add_task()
.withf(move |task| *task == expected_task_normal)
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_repository
.expect_try_add_task()
.withf(move |task| *task == expected_task_unicode)
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = post_domain_request(router.clone(), domain_normal).await;
let (status_unicode, response_json_unicode) =
post_domain_request(router, subdomain_unicode).await;
for (status, response_json, domain) in [
(status, response_json, domain_normal),
(status_unicode, response_json_unicode, subdomain_unicode),
] {
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(response_json["status"], "success");
assert_eq!(response_json["data"]["domain"], domain);
assert_eq!(
response_json["data"]["canister_id"],
"rrkah-fqaaa-aaaaa-aaaaq-cai"
);
assert_eq!(
response_json["message"].as_str().unwrap(),
"Domain registration request accepted and may take a few minutes to process"
);
}
}
#[tokio::test]
async fn test_post_domain_conflict_certificate_already_issued() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator.expect_validate().returning(|_| {
Box::pin(async { Ok(Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap()) })
});
let domain = "example.org";
let mut mock_repository = MockRepository::new();
mock_repository.expect_try_add_task().returning(|_| {
let domain = FQDN::from_str(domain).unwrap();
Box::pin(async { Err(RepositoryError::CertificateAlreadyIssued(domain)) })
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = post_domain_request(router, domain).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(response_json["status"], "error");
assert_eq!(
response_json["message"],
"Domain registration request failed"
);
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["errors"].as_str().unwrap(),
format!(
"conflict: Certificate for {domain} already exists; reissuance is not permitted."
)
);
}
#[tokio::test]
async fn test_post_domain_conflict_another_task_in_progress() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator.expect_validate().returning(|_| {
Box::pin(async { Ok(Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap()) })
});
let domain = "example.org";
let mut mock_repository = MockRepository::new();
mock_repository.expect_try_add_task().returning(|_| {
let domain = FQDN::from_str(domain).unwrap();
Box::pin(async { Err(RepositoryError::AnotherTaskInProgress(domain)) })
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = post_domain_request(router, domain).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], domain);
assert_eq!(
response_json["errors"].as_str().unwrap(),
format!(
"conflict: Another task for {domain} is already in progress. Please retry after it completes."
)
);
}
#[tokio::test]
async fn test_post_domain_bad_request_invalid_domain() {
let mock_validator = MockValidatesDomains::new();
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = post_domain_request(router, "invalid..domain").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "invalid..domain");
assert!(
response_json["errors"]
.as_str()
.unwrap()
.contains("Invalid domain")
);
}
#[tokio::test]
async fn test_post_domain_bad_request_with_validation_error() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator.expect_validate().returning(|_| {
Box::pin(async {
Err(ValidationError::MissingDnsCname {
src: "_acme-challenge.example.org.".to_string(),
dst: "_acme-challenge.example.org.icp2.io.".to_string(),
})
})
});
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = post_domain_request(router, "example.org").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["errors"].as_str().unwrap(),
"bad_request: missing DNS CNAME record from _acme-challenge.example.org. to _acme-challenge.example.org.icp2.io."
)
}
#[tokio::test]
async fn test_post_domain_internal_server_error_repository_failure() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator.expect_validate().returning(|_| {
Box::pin(async { Ok(Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap()) })
});
let mut mock_repository = MockRepository::new();
mock_repository.expect_try_add_task().returning(|_| {
Box::pin(async {
Err(RepositoryError::InternalError(anyhow::anyhow!(
"Some internal error"
)))
})
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = post_domain_request(router, "example.org").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["errors"],
"internal_server_error: An unexpected error occurred. Please try again later or contact support."
);
}
#[tokio::test]
async fn test_post_domain_very_long_domain() {
let mock_validator = MockValidatesDomains::new();
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let long_domain = "a".repeat(250) + ".example.org";
let (status, response_json) = post_domain_request(router, &long_domain).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["errors"], "bad_request: Domain is too long");
}
async fn get_domain_status_request(router: axum::Router, domain: &str) -> (StatusCode, Value) {
let uri = format!("/v1/{domain}");
let request = Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
let body_bytes = to_bytes(response.into_body(), BODY_LIMIT).await.unwrap();
let body_json: Value = serde_json::from_slice(&body_bytes).unwrap();
(status, body_json)
}
#[tokio::test]
async fn test_get_domain_status_success_various_statuses() {
let test_cases = vec![
("registered", RegistrationStatus::Registered),
("registering", RegistrationStatus::Registering),
("expired", RegistrationStatus::Expired),
];
let domains = vec!["example.org", "тест.unicode.org"];
for (expected_status_str, registration_status) in test_cases {
for domain in &domains {
let mock_validator = MockValidatesDomains::new();
let mut mock_repository = MockRepository::new();
let expected_canister_id =
Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
let domain_status = DomainStatus {
domain: FQDN::from_str(domain).unwrap(),
canister_id: Some(expected_canister_id),
status: registration_status.clone(),
};
mock_repository
.expect_get_domain_status()
.returning(move |_| {
let status = domain_status.clone();
Box::pin(async move { Ok(Some(status)) })
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_status_request(router, domain).await;
assert_eq!(
status,
StatusCode::OK,
"Failed for status: {expected_status_str} with domain: {domain}",
);
assert_eq!(response_json["status"], "success");
assert_eq!(response_json["data"]["domain"], *domain);
assert_eq!(
response_json["data"]["canister_id"],
"rrkah-fqaaa-aaaaa-aaaaq-cai"
);
assert_eq!(
response_json["data"]["registration_status"],
expected_status_str
);
assert_eq!(
response_json["message"].as_str().unwrap(),
"Registration status of the domain"
);
}
}
}
#[tokio::test]
async fn test_get_domain_status_success_failed() {
let mock_validator = MockValidatesDomains::new();
let mut mock_repository = MockRepository::new();
let failure_message = "some failure message";
let domain_status = DomainStatus {
domain: FQDN::from_str("example.org").unwrap(),
canister_id: None,
status: RegistrationStatus::Failed(failure_message.to_string()),
};
mock_repository
.expect_get_domain_status()
.returning(move |_| {
let status = domain_status.clone();
Box::pin(async move { Ok(Some(status)) })
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_status_request(router, "example.org").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(response_json["status"], "success");
assert_eq!(response_json["data"]["domain"], "example.org");
assert!(response_json["data"]["canister_id"].is_null());
assert_eq!(
response_json["data"]["registration_status"]["failed"],
"An unexpected error occurred during registration. Please try again later or contact support."
);
assert_eq!(
response_json["message"].as_str().unwrap(),
"Registration status of the domain"
);
}
#[tokio::test]
async fn test_get_domain_status_not_found() {
let mock_validator = MockValidatesDomains::new();
let mut mock_repository = MockRepository::new();
mock_repository
.expect_get_domain_status()
.returning(|_| Box::pin(async move { Ok(None) }));
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_status_request(router, "nonexistent.org").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "nonexistent.org");
assert_eq!(
response_json["message"].as_str().unwrap(),
"Registration status request failed"
);
assert_eq!(
response_json["errors"].as_str().unwrap(),
"not_found: Domain nonexistent.org not found"
);
}
#[tokio::test]
async fn test_get_domain_status_bad_request_invalid_domain() {
let mock_validator = MockValidatesDomains::new();
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_status_request(router, "invalid..domain").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "invalid..domain");
assert_eq!(
response_json["message"].as_str().unwrap(),
"Registration status request failed"
);
assert!(
response_json["errors"]
.as_str()
.unwrap()
.contains("Invalid domain")
);
}
#[tokio::test]
async fn test_get_domain_status_bad_request_empty_domain() {
let mock_validator = MockValidatesDomains::new();
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let uri = "/v1/".to_string(); let request = Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_domain_status_bad_request_very_long_domain() {
let mock_validator = MockValidatesDomains::new();
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let long_domain = "a".repeat(250) + ".example.org";
let (status, response_json) = get_domain_status_request(router, &long_domain).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], long_domain);
assert_eq!(
response_json["message"].as_str().unwrap(),
"Registration status request failed"
);
assert_eq!(
response_json["errors"].as_str().unwrap(),
"bad_request: Domain is too long"
);
}
#[tokio::test]
async fn test_get_domain_status_internal_server_error_repository_failure() {
let mock_validator = MockValidatesDomains::new();
let mut mock_repository = MockRepository::new();
mock_repository.expect_get_domain_status().returning(|_| {
Box::pin(async {
Err(RepositoryError::InternalError(anyhow::anyhow!(
"some real internal error"
)))
})
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_status_request(router, "example.org").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["message"].as_str().unwrap(),
"Registration status request failed"
);
assert_eq!(
response_json["errors"].as_str().unwrap(),
"internal_server_error: An unexpected error occurred. Please try again later or contact support."
);
}
#[tokio::test]
async fn test_get_domain_status_invalid_path() {
let mock_validator = MockValidatesDomains::new();
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let request = Request::builder()
.method("GET")
.uri("/v1/example.org/wrong-path")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
async fn get_domain_validate_request(
router: axum::Router,
domain: &str,
) -> (StatusCode, Value) {
let uri = format!("/v1/{domain}/validate");
let request = Request::builder()
.method("GET")
.uri(uri)
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
let body_bytes = to_bytes(response.into_body(), BODY_LIMIT).await.unwrap();
let body_json: Value = serde_json::from_slice(&body_bytes).unwrap();
(status, body_json)
}
#[tokio::test]
async fn test_get_domain_validate_success() {
let domains = vec!["example.org", "тест.unicode.org"];
for domain in &domains {
let mut mock_validator = MockValidatesDomains::new();
let expected_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
mock_validator
.expect_validate()
.returning(move |_| Box::pin(async move { Ok(expected_canister_id) }));
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_validate_request(router, domain).await;
assert_eq!(status, StatusCode::OK,);
assert_eq!(response_json["status"], "success");
assert_eq!(response_json["data"]["domain"], *domain);
assert_eq!(
response_json["data"]["canister_id"],
"rrkah-fqaaa-aaaaa-aaaaq-cai"
);
assert_eq!(response_json["data"]["validation_status"], "valid");
assert_eq!(
response_json["message"].as_str().unwrap(),
"Domain is eligible for registration: DNS records are valid and canister ownership is verified"
);
}
}
#[tokio::test]
async fn test_get_domain_validate_invalid_dns_record() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator.expect_validate().returning(|_| {
Box::pin(async {
Err(ValidationError::MissingDnsCname {
src: "_acme-challenge.example.org.".to_string(),
dst: "_acme-challenge.example.org.icp2.io.".to_string(),
})
})
});
let mock_repository = MockRepository::new();
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = get_domain_validate_request(router, "example.org").await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(response_json["status"], "error");
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["message"].as_str().unwrap(),
"Failed to validate DNS records or verify canister ownership"
);
assert_eq!(
response_json["errors"].as_str().unwrap(),
"unprocessable_entity: missing DNS CNAME record from _acme-challenge.example.org. to _acme-challenge.example.org.icp2.io."
);
}
async fn domain_update_request(router: axum::Router, domain: &str) -> (StatusCode, Value) {
let uri = format!("/v1/{domain}");
let request = Request::builder()
.method("PATCH")
.uri(uri)
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
let body_bytes = to_bytes(response.into_body(), BODY_LIMIT).await.unwrap();
let body_json: Value = serde_json::from_slice(&body_bytes).unwrap();
(status, body_json)
}
#[tokio::test]
async fn test_post_domain_update_success_accepted() {
let mut mock_validator = MockValidatesDomains::new();
let expected_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
mock_validator
.expect_validate()
.returning(move |_| Box::pin(async move { Ok(expected_canister_id) }));
let mut mock_repository = MockRepository::new();
let domain_normal = "example.org";
let subdomain_unicode = "тест.unicode.org";
let expected_task_normal = InputTask::new(
TaskKind::Update,
FQDN::from_str(domain_normal).unwrap(),
false,
);
let expected_task_unicode = InputTask::new(
TaskKind::Update,
FQDN::from_str(subdomain_unicode).unwrap(),
false,
);
mock_repository
.expect_try_add_task()
.withf(move |task| *task == expected_task_normal)
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_repository
.expect_try_add_task()
.withf(move |task| *task == expected_task_unicode)
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = domain_update_request(router.clone(), domain_normal).await;
let (status_unicode, response_json_unicode) =
domain_update_request(router, subdomain_unicode).await;
for (status, response_json, domain) in [
(status, response_json, domain_normal),
(status_unicode, response_json_unicode, subdomain_unicode),
] {
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(response_json["status"], "success");
assert_eq!(response_json["data"]["domain"], domain);
assert_eq!(
response_json["data"]["canister_id"],
"rrkah-fqaaa-aaaaa-aaaaq-cai"
);
assert_eq!(
response_json["message"].as_str().unwrap(),
"Update domain registration request accepted and may take a few minutes to process"
);
}
}
#[tokio::test]
async fn test_post_domain_update_bad_request_missing_certificate() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator.expect_validate().returning(|_| {
Box::pin(async { Ok(Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap()) })
});
let mut mock_repository = MockRepository::new();
mock_repository.expect_try_add_task().returning(|_| {
let domain = FQDN::from_str("example.org").unwrap();
Box::pin(async { Err(RepositoryError::MissingCertificateForUpdate(domain)) })
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = domain_update_request(router, "example.org").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(response_json["status"], "error");
assert_eq!(
response_json["message"],
"Update domain registration request failed"
);
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["errors"].as_str().unwrap(),
"bad_request: Cannot update domain-to-canister mapping: no valid certificate found for domain example.org."
);
}
async fn delete_domain_request(router: axum::Router, domain: &str) -> (StatusCode, Value) {
let uri = format!("/v1/{domain}");
let request = Request::builder()
.method("DELETE")
.uri(uri)
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
let body_bytes = to_bytes(response.into_body(), BODY_LIMIT).await.unwrap();
let body_json: Value = serde_json::from_slice(&body_bytes).unwrap();
(status, body_json)
}
#[tokio::test]
async fn test_delete_domain_success_accepted() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator
.expect_validate_deletion()
.returning(|_| Box::pin(async { Ok(()) }));
let mut mock_repository = MockRepository::new();
let domain_normal = "example.org";
let subdomain_unicode = "тест.unicode.org";
let expected_task_normal = InputTask::new(
TaskKind::Delete,
FQDN::from_str(domain_normal).unwrap(),
false,
);
let expected_task_unicode = InputTask::new(
TaskKind::Delete,
FQDN::from_str(subdomain_unicode).unwrap(),
false,
);
mock_repository
.expect_try_add_task()
.withf(move |task| *task == expected_task_normal)
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_repository
.expect_try_add_task()
.withf(move |task| *task == expected_task_unicode)
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = delete_domain_request(router.clone(), domain_normal).await;
let (status_unicode, response_json_unicode) =
delete_domain_request(router, subdomain_unicode).await;
for (status, response_json, domain) in [
(status, response_json, domain_normal),
(status_unicode, response_json_unicode, subdomain_unicode),
] {
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(response_json["status"], "success");
assert_eq!(response_json["data"]["domain"], domain);
assert!(response_json["data"]["canister_id"].is_null());
assert_eq!(
response_json["message"].as_str().unwrap(),
"Delete domain registration request accepted and may take a few minutes to process"
);
}
}
#[tokio::test]
async fn test_delete_domain_not_found() {
let mut mock_validator = MockValidatesDomains::new();
mock_validator
.expect_validate_deletion()
.returning(|_| Box::pin(async { Ok(()) }));
let mut mock_repository = MockRepository::new();
mock_repository.expect_try_add_task().returning(|_| {
let domain = FQDN::from_str("example.org").unwrap();
Box::pin(async { Err(RepositoryError::DomainNotFound(domain)) })
});
let router = create_test_router(mock_repository, mock_validator);
let (status, response_json) = delete_domain_request(router, "example.org").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(response_json["status"], "error");
assert_eq!(
response_json["message"],
"Delete domain registration request failed"
);
assert_eq!(response_json["data"]["domain"], "example.org");
assert_eq!(
response_json["errors"].as_str().unwrap(),
"not_found: Domain example.org not found."
);
}
#[tokio::test]
async fn test_rate_limiting_by_ip() {
use tower::ServiceExt;
let mut mock_validator = MockValidatesDomains::new();
let expected_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
mock_validator
.expect_validate()
.returning(move |_| Box::pin(async move { Ok(expected_canister_id) }))
.times(2);
let mut mock_repository = MockRepository::new();
mock_repository
.expect_try_add_task()
.returning(|_| Box::pin(async { Ok(()) }))
.times(2);
let rate_limits = RateLimitConfig {
limit_create: Some(1),
..Default::default()
};
let router =
create_test_router_with_rate_limiter(mock_repository, mock_validator, rate_limits);
let test_ip = "192.168.1.100";
let mut service = router.into_service();
let request1 = Request::builder()
.method("POST")
.uri("/v1/example1.org")
.header("x-real-ip", test_ip)
.body(Body::empty())
.unwrap();
let request2 = Request::builder()
.method("POST")
.uri("/v1/example2.org")
.header("x-real-ip", test_ip)
.body(Body::empty())
.unwrap();
let request3 = Request::builder()
.method("POST")
.uri("/v1/example3.org")
.header("x-real-ip", test_ip)
.body(Body::empty())
.unwrap();
let response1 = service.ready().await.unwrap().call(request1).await.unwrap();
let response2 = service.ready().await.unwrap().call(request2).await.unwrap();
let response3 = service.ready().await.unwrap().call(request3).await.unwrap();
let status1 = response1.status();
assert_eq!(status1, StatusCode::ACCEPTED);
let status2 = response2.status();
assert_eq!(status2, StatusCode::ACCEPTED);
let status3 = response3.status();
assert_eq!(status3, StatusCode::TOO_MANY_REQUESTS);
let body_bytes = to_bytes(response3.into_body(), BODY_LIMIT).await.unwrap();
assert_eq!(body_bytes.to_vec(), b"Too many requests");
}
}