diff --git a/src/challenge.rs b/src/challenge.rs index 7322f5b..d2d3727 100644 --- a/src/challenge.rs +++ b/src/challenge.rs @@ -1,25 +1,93 @@ use async_trait::async_trait; -/// Trait for verifying domain ownership challenges (DNS-01 and HTTP-01). +use crate::config::ChallengeConfig; + +/// Trait for challenge verification — allows mocking in tests. #[async_trait] -pub trait ChallengeVerifier: Send + Sync + std::fmt::Debug { - async fn verify_dns01(&self, domain: &str, expected_token: &str) -> Result; - async fn verify_http01(&self, domain: &str, expected_token: &str) -> Result; +pub trait ChallengeVerifier: Send + Sync { + async fn verify_dns( + &self, + domain: &str, + expected_token: &str, + config: &ChallengeConfig, + ) -> Result; + + async fn verify_http( + &self, + domain: &str, + expected_token: &str, + config: &ChallengeConfig, + ) -> Result; } -/// Production implementation using real DNS and HTTP lookups. -#[derive(Debug)] +/// Real implementation using DNS lookups and HTTP requests. pub struct RealChallengeVerifier; #[async_trait] impl ChallengeVerifier for RealChallengeVerifier { - async fn verify_dns01(&self, _domain: &str, _expected_token: &str) -> Result { - // TODO: implement with hickory-resolver in a later task + async fn verify_dns( + &self, + domain: &str, + expected_token: &str, + config: &ChallengeConfig, + ) -> Result { + use hickory_resolver::TokioResolver; + + let resolver = TokioResolver::builder_tokio() + .map_err(|e| format!("resolver error: {e}"))? + .build(); + + let lookup_name = format!("{}.{}", config.dns_txt_prefix, domain); + let response = resolver + .txt_lookup(&lookup_name) + .await + .map_err(|e| format!("DNS lookup failed: {e}"))?; + + for record in response.iter() { + let txt = record.to_string(); + if txt.trim_matches('"') == expected_token { + return Ok(true); + } + } + Ok(false) } - async fn verify_http01(&self, _domain: &str, _expected_token: &str) -> Result { - // TODO: implement with reqwest in a later task - Ok(false) + async fn verify_http( + &self, + domain: &str, + expected_token: &str, + config: &ChallengeConfig, + ) -> Result { + let url = format!( + "https://{}/{}/{}", + domain, config.http_well_known_path, expected_token + ); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + + let response = client + .get(&url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {e}"))?; + + Ok(response.status().is_success()) + } +} + +/// Mock that always succeeds — for testing. +pub struct MockChallengeVerifier; + +#[async_trait] +impl ChallengeVerifier for MockChallengeVerifier { + async fn verify_dns(&self, _: &str, _: &str, _: &ChallengeConfig) -> Result { + Ok(true) + } + async fn verify_http(&self, _: &str, _: &str, _: &ChallengeConfig) -> Result { + Ok(true) } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..7a04c02 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,66 @@ +use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use std::sync::Arc; +use webfingerd::cache::Cache; +use webfingerd::config::*; +use webfingerd::state::AppState; + +pub async fn setup_test_db() -> DatabaseConnection { + let opt = ConnectOptions::new("sqlite::memory:"); + let db = Database::connect(opt).await.unwrap(); + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "PRAGMA journal_mode=WAL".to_string(), + )) + .await + .unwrap(); + migration::Migrator::up(&db, None).await.unwrap(); + db +} + +pub fn test_settings() -> Settings { + Settings { + server: ServerConfig { + listen: "127.0.0.1:0".into(), + base_url: "http://localhost:8080".into(), + }, + database: DatabaseConfig { + path: ":memory:".into(), + wal_mode: true, + }, + cache: CacheConfig { + reaper_interval_secs: 1, + }, + rate_limit: RateLimitConfig { + public_rpm: 1000, + api_rpm: 1000, + batch_rpm: 100, + batch_max_links: 500, + }, + challenge: ChallengeConfig { + dns_txt_prefix: "_webfinger-challenge".into(), + http_well_known_path: ".well-known/webfinger-verify".into(), + challenge_ttl_secs: 3600, + }, + ui: UiConfig { + enabled: false, + session_secret: "test-secret-at-least-32-bytes-long-for-signing".into(), + }, + } +} + +pub async fn test_state() -> AppState { + test_state_with_settings(test_settings()).await +} + +pub async fn test_state_with_settings(settings: Settings) -> AppState { + let db = setup_test_db().await; + let cache = Cache::new(); + cache.hydrate(&db).await.unwrap(); + AppState { + db, + cache, + settings: Arc::new(settings), + challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier), + } +}