Skip to main content

doido_auth/
testing.rs

1//! Test helpers and in-memory fakes for auth integration tests.
2
3use crate::config::AuthConfig;
4use crate::error::AuthError;
5use crate::handlers::{register_user, sign_in_with_session};
6use crate::jwt::JwtStrategy;
7use crate::state::{set_state, AuthState};
8use crate::user::AuthUser;
9use crate::RegisterableAuthUser;
10use doido_controller::axum::body::Body;
11use doido_controller::axum::Router;
12use doido_controller::session::Session;
13use doido_model::password::{hash_password_with_cost, HasSecurePassword};
14use doido_model::sea_orm::DatabaseConnection;
15use http::{Request, StatusCode};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::{Arc, Mutex};
19use tower::ServiceExt;
20
21const TEST_COST: u32 = 4;
22
23static AUTH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
24
25/// Holds the auth test lock for the duration of a test (serialises global auth state).
26pub struct AuthTestGuard {
27    _lock: std::sync::MutexGuard<'static, ()>,
28}
29
30/// Simple in-memory user for auth tests.
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
32pub struct TestUser {
33    pub id: i64,
34    pub email: String,
35    pub password_digest: String,
36}
37
38impl HasSecurePassword for TestUser {
39    fn password_digest(&self) -> &str {
40        &self.password_digest
41    }
42}
43
44impl AuthUser for TestUser {
45    type Id = i64;
46
47    fn id(&self) -> Self::Id {
48        self.id
49    }
50
51    fn email(&self) -> &str {
52        &self.email
53    }
54
55    fn password_digest(&self) -> Option<&str> {
56        Some(&self.password_digest)
57    }
58
59    async fn find_by_email(
60        _db: &DatabaseConnection,
61        email: &str,
62    ) -> doido_core::Result<Option<Self>> {
63        Ok(store()
64            .lock()
65            .unwrap()
66            .values()
67            .find(|u| u.email == email)
68            .cloned())
69    }
70
71    async fn find_by_id(
72        _db: &DatabaseConnection,
73        id: Self::Id,
74    ) -> doido_core::Result<Option<Self>> {
75        Ok(store().lock().unwrap().get(&id).cloned())
76    }
77}
78
79impl RegisterableAuthUser for TestUser {
80    async fn register(
81        _db: &DatabaseConnection,
82        email: String,
83        password_digest: String,
84    ) -> doido_core::Result<Self> {
85        let store = store();
86        let mut map = store.lock().unwrap();
87        let id = (map.len() as i64) + 1;
88        let user = TestUser {
89            id,
90            email,
91            password_digest,
92        };
93        map.insert(id, user.clone());
94        Ok(user)
95    }
96}
97
98static TEST_STORE: std::sync::OnceLock<Arc<Mutex<HashMap<i64, TestUser>>>> =
99    std::sync::OnceLock::new();
100
101fn store() -> Arc<Mutex<HashMap<i64, TestUser>>> {
102    TEST_STORE
103        .get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
104        .clone()
105}
106
107/// Reset the in-memory user store between tests.
108pub fn reset_store() {
109    store().lock().unwrap().clear();
110}
111
112/// Build a minimal auth config for tests: cookie strategy, only the
113/// `database_authenticatable` module. Kept minimal so module behaviors
114/// (validatable, trackable, …) don't implicitly affect unrelated tests — a test
115/// that exercises a module should enable it explicitly.
116pub fn test_auth_config() -> AuthConfig {
117    AuthConfig {
118        modules: vec![crate::config::AuthModule::DatabaseAuthenticatable],
119        ..Default::default()
120    }
121}
122
123/// Build auth config with JWT enabled.
124pub fn test_jwt_auth_config(secret: &str) -> AuthConfig {
125    AuthConfig {
126        strategies: vec!["cookie".into(), "jwt".into()],
127        jwt: Some(crate::config::JwtConfig {
128            secret: secret.into(),
129            access_ttl: 900,
130            refresh_ttl: 604_800,
131            issuer: Some("test".into()),
132        }),
133        ..Default::default()
134    }
135}
136
137/// Initialise in-memory auth state for tests.
138///
139/// Returns a guard that must be held for the whole test so parallel tests do not
140/// clobber process-global auth state.
141pub async fn init_test_auth(
142    db: DatabaseConnection,
143    config: AuthConfig,
144) -> Result<AuthTestGuard, AuthError> {
145    let guard = AUTH_TEST_LOCK.lock().expect("auth test lock");
146    reset_store();
147    crate::state::reset_state();
148    let _ = doido_model::pool::set_pool(db.clone());
149    set_state(AuthState::build(db, config)?);
150    Ok(AuthTestGuard { _lock: guard })
151}
152
153/// Create a user in the in-memory store.
154pub async fn create_test_user(
155    db: &DatabaseConnection,
156    email: &str,
157    password: &str,
158) -> Result<TestUser, AuthError> {
159    let users = store();
160    register_user(db, email, password, |email, digest| async move {
161        let mut map = users.lock().unwrap();
162        let id = (map.len() as i64) + 1;
163        let user = TestUser {
164            id,
165            email,
166            password_digest: digest,
167        };
168        map.insert(id, user.clone());
169        Ok(user)
170    })
171    .await
172}
173
174/// Issue a signed JWT access token for a test user id.
175pub fn jwt_for_user(config: &crate::config::JwtConfig, user_id: i64) -> String {
176    let strategy = JwtStrategy::new(config.clone()).expect("jwt config");
177    strategy
178        .issue_tokens(&serde_json::json!(user_id))
179        .expect("issue tokens")
180        .access_token
181}
182
183/// The status + body captured from a test request.
184pub struct TestResponse {
185    pub status: StatusCode,
186    pub body: String,
187    pub set_cookie: Option<String>,
188}
189
190/// Send a request to `router` in-process and capture the response.
191pub async fn send(router: Router, method: &str, uri: &str, body: &str) -> TestResponse {
192    send_with_headers(router, method, uri, body, &[]).await
193}
194
195/// Send a request with extra headers.
196pub async fn send_with_headers(
197    router: Router,
198    method: &str,
199    uri: &str,
200    body: &str,
201    headers: &[(&str, &str)],
202) -> TestResponse {
203    let mut builder = Request::builder().method(method).uri(uri);
204    if !body.is_empty() && (method == "POST" || method == "PATCH") {
205        builder = builder
206            .header(http::header::CONTENT_TYPE, "application/json")
207            .header(http::header::ACCEPT, "application/json");
208    }
209    for (k, v) in headers {
210        builder = builder.header(*k, *v);
211    }
212    let request = builder
213        .body(Body::from(body.to_string()))
214        .expect("valid test request");
215    let response = router
216        .oneshot(request)
217        .await
218        .expect("router handled the request");
219    let status = response.status();
220    let set_cookie = response
221        .headers()
222        .get(http::header::SET_COOKIE)
223        .and_then(|v| v.to_str().ok())
224        .map(str::to_string);
225    let bytes = doido_controller::axum::body::to_bytes(response.into_body(), usize::MAX)
226        .await
227        .expect("read response body");
228    TestResponse {
229        status,
230        body: String::from_utf8_lossy(&bytes).to_string(),
231        set_cookie,
232    }
233}
234
235/// Hash a password at the low test cost.
236pub fn hash_test_password(password: &str) -> String {
237    hash_password_with_cost(password, TEST_COST).expect("hash")
238}
239
240/// Sign a user into a fresh session (helper for session strategy tests).
241pub fn session_for_user(user: &TestUser) -> Session {
242    let mut session = Session::new();
243    sign_in_with_session(&mut session, user);
244    session
245}
246
247pub use crate::jwt::JwtStrategy as TestJwtStrategy;
248pub use crate::session::SessionStrategy as TestSessionStrategy;