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