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 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
24pub struct AuthTestGuard {
26 _lock: std::sync::MutexGuard<'static, ()>,
27}
28
29#[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
87pub fn reset_store() {
89 store().lock().unwrap().clear();
90}
91
92pub fn test_auth_config() -> AuthConfig {
94 AuthConfig::default()
95}
96
97pub 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
111pub 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
126pub 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
147pub 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
156pub struct TestResponse {
158 pub status: StatusCode,
159 pub body: String,
160 pub set_cookie: Option<String>,
161}
162
163pub async fn send(router: Router, method: &str, uri: &str, body: &str) -> TestResponse {
165 send_with_headers(router, method, uri, body, &[]).await
166}
167
168pub 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
206pub fn hash_test_password(password: &str) -> String {
208 hash_password_with_cost(password, TEST_COST).expect("hash")
209}
210
211pub 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;