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 default auth config for tests (cookie strategy only).
113pub fn test_auth_config() -> AuthConfig {
114    AuthConfig::default()
115}
116
117/// Build auth config with JWT enabled.
118pub fn test_jwt_auth_config(secret: &str) -> AuthConfig {
119    AuthConfig {
120        strategies: vec!["cookie".into(), "jwt".into()],
121        jwt: Some(crate::config::JwtConfig {
122            secret: secret.into(),
123            access_ttl: 900,
124            refresh_ttl: 604_800,
125            issuer: Some("test".into()),
126        }),
127        ..Default::default()
128    }
129}
130
131/// Initialise in-memory auth state for tests.
132///
133/// Returns a guard that must be held for the whole test so parallel tests do not
134/// clobber process-global auth state.
135pub async fn init_test_auth(
136    db: DatabaseConnection,
137    config: AuthConfig,
138) -> Result<AuthTestGuard, AuthError> {
139    let guard = AUTH_TEST_LOCK.lock().expect("auth test lock");
140    reset_store();
141    crate::state::reset_state();
142    let _ = doido_model::pool::set_pool(db.clone());
143    set_state(AuthState::build(db, config)?);
144    Ok(AuthTestGuard { _lock: guard })
145}
146
147/// Create a user in the in-memory store.
148pub async fn create_test_user(
149    db: &DatabaseConnection,
150    email: &str,
151    password: &str,
152) -> Result<TestUser, AuthError> {
153    let users = store();
154    register_user(db, email, password, |email, digest| async move {
155        let mut map = users.lock().unwrap();
156        let id = (map.len() as i64) + 1;
157        let user = TestUser {
158            id,
159            email,
160            password_digest: digest,
161        };
162        map.insert(id, user.clone());
163        Ok(user)
164    })
165    .await
166}
167
168/// Issue a signed JWT access token for a test user id.
169pub fn jwt_for_user(config: &crate::config::JwtConfig, user_id: i64) -> String {
170    let strategy = JwtStrategy::new(config.clone()).expect("jwt config");
171    strategy
172        .issue_tokens(&serde_json::json!(user_id))
173        .expect("issue tokens")
174        .access_token
175}
176
177/// The status + body captured from a test request.
178pub struct TestResponse {
179    pub status: StatusCode,
180    pub body: String,
181    pub set_cookie: Option<String>,
182}
183
184/// Send a request to `router` in-process and capture the response.
185pub async fn send(router: Router, method: &str, uri: &str, body: &str) -> TestResponse {
186    send_with_headers(router, method, uri, body, &[]).await
187}
188
189/// Send a request with extra headers.
190pub async fn send_with_headers(
191    router: Router,
192    method: &str,
193    uri: &str,
194    body: &str,
195    headers: &[(&str, &str)],
196) -> TestResponse {
197    let mut builder = Request::builder().method(method).uri(uri);
198    if !body.is_empty() && (method == "POST" || method == "PATCH") {
199        builder = builder
200            .header(http::header::CONTENT_TYPE, "application/json")
201            .header(http::header::ACCEPT, "application/json");
202    }
203    for (k, v) in headers {
204        builder = builder.header(*k, *v);
205    }
206    let request = builder
207        .body(Body::from(body.to_string()))
208        .expect("valid test request");
209    let response = router
210        .oneshot(request)
211        .await
212        .expect("router handled the request");
213    let status = response.status();
214    let set_cookie = response
215        .headers()
216        .get(http::header::SET_COOKIE)
217        .and_then(|v| v.to_str().ok())
218        .map(str::to_string);
219    let bytes = doido_controller::axum::body::to_bytes(response.into_body(), usize::MAX)
220        .await
221        .expect("read response body");
222    TestResponse {
223        status,
224        body: String::from_utf8_lossy(&bytes).to_string(),
225        set_cookie,
226    }
227}
228
229/// Hash a password at the low test cost.
230pub fn hash_test_password(password: &str) -> String {
231    hash_password_with_cost(password, TEST_COST).expect("hash")
232}
233
234/// Sign a user into a fresh session (helper for session strategy tests).
235pub fn session_for_user(user: &TestUser) -> Session {
236    let mut session = Session::new();
237    sign_in_with_session(&mut session, user);
238    session
239}
240
241pub use crate::jwt::JwtStrategy as TestJwtStrategy;
242pub use crate::session::SessionStrategy as TestSessionStrategy;