1use 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
25pub struct AuthTestGuard {
27 _lock: std::sync::MutexGuard<'static, ()>,
28}
29
30#[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
107pub fn reset_store() {
109 store().lock().unwrap().clear();
110}
111
112pub fn test_auth_config() -> AuthConfig {
117 AuthConfig {
118 modules: vec![crate::config::AuthModule::DatabaseAuthenticatable],
119 ..Default::default()
120 }
121}
122
123pub 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
137pub 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
153pub 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
174pub 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
183pub struct TestResponse {
185 pub status: StatusCode,
186 pub body: String,
187 pub set_cookie: Option<String>,
188}
189
190pub async fn send(router: Router, method: &str, uri: &str, body: &str) -> TestResponse {
192 send_with_headers(router, method, uri, body, &[]).await
193}
194
195pub 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
235pub fn hash_test_password(password: &str) -> String {
237 hash_password_with_cost(password, TEST_COST).expect("hash")
238}
239
240pub 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;