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 {
114 AuthConfig::default()
115}
116
117pub 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
131pub 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
147pub 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
168pub 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
177pub struct TestResponse {
179 pub status: StatusCode,
180 pub body: String,
181 pub set_cookie: Option<String>,
182}
183
184pub async fn send(router: Router, method: &str, uri: &str, body: &str) -> TestResponse {
186 send_with_headers(router, method, uri, body, &[]).await
187}
188
189pub 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
229pub fn hash_test_password(password: &str) -> String {
231 hash_password_with_cost(password, TEST_COST).expect("hash")
232}
233
234pub 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;