1use super::migration_support::{
10 register_migration, render_migration_file, MIGRATION_LIB_BASE, MIGRATION_SRC_DIR,
11};
12use super::route_injector::{
13 inject_auth_routes, inject_auth_routes_only, read_models_mod, read_routes,
14 register_model_module, MODELS_MOD_PATH, ROUTES_PATH,
15};
16use super::template;
17use super::{AuthGenerator, GeneratedFile};
18use crate::config::AuthModule;
19use chrono::Utc;
20use doido_core::Result;
21
22pub struct AuthInstallGenerator;
23
24const IMPORTS: &str = "use doido::model::migration::{create_table, drop_table};";
25
26fn module_migration_columns(module: AuthModule) -> &'static [&'static str] {
30 match module {
31 AuthModule::Rememberable => &[" t.timestamp(\"remember_created_at\");"],
32 AuthModule::Trackable => &[
33 " t.integer(\"sign_in_count\").not_null().default(0);",
34 " t.timestamp(\"current_sign_in_at\");",
35 " t.timestamp(\"last_sign_in_at\");",
36 " t.string(\"current_sign_in_ip\");",
37 " t.string(\"last_sign_in_ip\");",
38 ],
39 AuthModule::Recoverable => &[
40 " t.string(\"reset_password_token\");",
41 " t.timestamp(\"reset_password_sent_at\");",
42 ],
43 AuthModule::Confirmable => &[
44 " t.string(\"confirmation_token\");",
45 " t.timestamp(\"confirmed_at\");",
46 " t.timestamp(\"confirmation_sent_at\");",
47 " t.string(\"unconfirmed_email\");",
48 ],
49 AuthModule::Lockable => &[
50 " t.integer(\"failed_attempts\").not_null().default(0);",
51 " t.string(\"unlock_token\");",
52 " t.timestamp(\"locked_at\");",
53 ],
54 AuthModule::TwoFactorAuthenticatable => &[
55 " t.string(\"two_factor_secret\");",
56 " t.boolean(\"two_factor_enabled\").not_null().default(false);",
57 ],
58 _ => &[],
59 }
60}
61
62fn module_entity_fields(module: AuthModule) -> &'static [&'static str] {
66 match module {
67 AuthModule::Rememberable => &[" pub remember_created_at: Option<DateTimeUtc>,"],
68 AuthModule::Trackable => &[
69 " pub sign_in_count: i32,",
70 " pub current_sign_in_at: Option<DateTimeUtc>,",
71 " pub last_sign_in_at: Option<DateTimeUtc>,",
72 " pub current_sign_in_ip: Option<String>,",
73 " pub last_sign_in_ip: Option<String>,",
74 ],
75 AuthModule::Recoverable => &[
76 " pub reset_password_token: Option<String>,",
77 " pub reset_password_sent_at: Option<DateTimeUtc>,",
78 ],
79 AuthModule::Confirmable => &[
80 " pub confirmation_token: Option<String>,",
81 " pub confirmed_at: Option<DateTimeUtc>,",
82 " pub confirmation_sent_at: Option<DateTimeUtc>,",
83 " pub unconfirmed_email: Option<String>,",
84 ],
85 AuthModule::Lockable => &[
86 " pub failed_attempts: i32,",
87 " pub unlock_token: Option<String>,",
88 " pub locked_at: Option<DateTimeUtc>,",
89 ],
90 AuthModule::TwoFactorAuthenticatable => &[
91 " pub two_factor_secret: Option<String>,",
92 " pub two_factor_enabled: bool,",
93 ],
94 _ => &[],
95 }
96}
97
98fn users_up_body(modules: &[AuthModule]) -> String {
99 let mut body = String::from(
100 " create_table(manager, \"users\", |t| {\n\
101 \x20 t.string(\"email\").not_null().unique_key();\n\
102 \x20 t.string(\"password_digest\").not_null();\n",
103 );
104 for module in AuthModule::ALL {
105 if modules.contains(&module) {
106 for line in module_migration_columns(module) {
107 body.push_str(line);
108 body.push('\n');
109 }
110 }
111 }
112 body.push_str(
113 " t.timestamp(\"created_at\").not_null();\n\
114 \x20 t.timestamp(\"updated_at\").not_null();\n\
115 \x20 })\n\
116 \x20 .await\n",
117 );
118 body
119}
120
121const DOWN_BODY: &str = " drop_table(manager, \"users\").await\n";
122
123fn modules_yaml(modules: &[AuthModule]) -> String {
124 let mut s = String::from(" modules:\n");
125 for module in AuthModule::ALL {
126 if modules.contains(&module) {
127 s.push_str(" - ");
128 s.push_str(module.as_str());
129 s.push('\n');
130 }
131 }
132 s
133}
134
135fn auth_section(modules: &[AuthModule]) -> String {
136 let enabled = modules.contains(&AuthModule::TwoFactorAuthenticatable);
137 format!(
138 "\nauth:\n user_model: User\n{} strategies:\n - cookie\n two_factor:\n enabled: {enabled}\n issuer: MyApp\n routes:\n prefix: /users\n",
139 modules_yaml(modules)
140 )
141}
142
143fn config_file(path: &str, modules: &[AuthModule]) -> Option<GeneratedFile> {
144 let existing = std::fs::read_to_string(path).ok()?;
145 if existing.contains("\nauth:") || existing.starts_with("auth:") {
146 return None;
147 }
148 Some(GeneratedFile {
149 path: path.to_string(),
150 content: format!("{}{}", existing.trim_end(), auth_section(modules)),
151 })
152}
153
154fn user_model() -> String {
155 template("user.rs.template").to_string()
156}
157
158fn user_entity(modules: &[AuthModule]) -> String {
159 let mut fields = String::new();
160 for module in AuthModule::ALL {
161 if modules.contains(&module) {
162 for line in module_entity_fields(module) {
163 fields.push_str(line);
164 fields.push('\n');
165 }
166 }
167 }
168 template("user_entity.rs.template").replace("{module_fields}", &fields)
169}
170
171fn selected_modules(args: &[&str]) -> (Vec<AuthModule>, bool) {
175 let explicit = args.iter().find_map(|a| a.strip_prefix("--modules="));
176 let mut modules: Vec<AuthModule> = match explicit {
177 Some(list) => list
178 .split(',')
179 .filter_map(|s| AuthModule::from_name(s.trim()))
180 .collect(),
181 None => crate::config::AuthConfig::default().modules,
182 };
183 if !modules.contains(&AuthModule::DatabaseAuthenticatable) {
184 modules.insert(0, AuthModule::DatabaseAuthenticatable);
185 }
186 if args.contains(&"--two-factor") && !modules.contains(&AuthModule::TwoFactorAuthenticatable) {
187 modules.push(AuthModule::TwoFactorAuthenticatable);
188 }
189 (modules, explicit.is_some())
190}
191
192fn entities_mod(existing: &str) -> String {
193 doido_model::entities::register_entity_module(existing, "users")
194}
195
196impl AuthGenerator for AuthInstallGenerator {
197 fn name(&self) -> &str {
198 "auth:install"
199 }
200
201 fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
202 let (modules, explicit_modules) = selected_modules(args);
203
204 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
205 let migration_module = format!("m{timestamp}_create_users_table");
206 let migration = render_migration_file(
207 &migration_module,
208 IMPORTS,
209 &users_up_body(&modules),
210 DOWN_BODY,
211 );
212
213 let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
214 let existing =
215 std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
216 let lib = register_migration(&existing, &migration_module);
217
218 let models_mod = register_model_module(&read_models_mod(), "user");
219 let entities_mod_path = "app/models/_entities/mod.rs";
220 let entities_mod_base = std::fs::read_to_string(entities_mod_path).unwrap_or_else(|_| {
221 include_str!("../../templates/new/app/models/_entities/mod.rs").to_string()
222 });
223 let entities_mod = entities_mod(&entities_mod_base);
224 let routes = if explicit_modules {
229 let cfg = crate::config::AuthConfig {
230 modules: modules.clone(),
231 ..Default::default()
232 };
233 let groups = cfg.enabled_route_groups();
234 inject_auth_routes_only(&read_routes(), &groups)
235 } else {
236 inject_auth_routes(&read_routes())
237 };
238
239 let mut files = vec![
240 GeneratedFile {
241 path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
242 content: migration,
243 },
244 GeneratedFile {
245 path: lib_path,
246 content: lib,
247 },
248 GeneratedFile {
249 path: "app/models/_entities/users.rs".to_string(),
250 content: user_entity(&modules),
251 },
252 GeneratedFile {
253 path: entities_mod_path.to_string(),
254 content: entities_mod,
255 },
256 GeneratedFile {
257 path: "app/models/user.rs".to_string(),
258 content: user_model(),
259 },
260 GeneratedFile {
261 path: MODELS_MOD_PATH.to_string(),
262 content: models_mod,
263 },
264 GeneratedFile {
265 path: ROUTES_PATH.to_string(),
266 content: routes,
267 },
268 ];
269
270 if let Some(f) = config_file("config/development.yml", &modules) {
271 files.push(f);
272 }
273 if let Some(f) = config_file("config/test.yml", &modules) {
274 files.push(f);
275 }
276
277 Ok(files)
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn emits_users_migration_and_bare_builtin_routes() {
287 let files = AuthInstallGenerator.generate(&[]).unwrap();
288 let migration = files
289 .iter()
290 .find(|f| f.path.contains("create_users_table"))
291 .expect("users migration");
292 assert!(migration.content.contains("password_digest"));
293 assert!(migration
294 .content
295 .contains("impl MigrationName for Migration"));
296
297 let routes = files
298 .iter()
299 .find(|f| f.path == ROUTES_PATH)
300 .expect("routes.rs");
301 assert!(routes.content.contains("auth_routes!(User);"));
303 assert!(routes.content.contains("doido::auth::routes!"));
304 assert!(routes
307 .content
308 .contains("use crate::models::user::Model as User;"));
309 assert!(!routes.content.contains("controllers: {"));
311 assert!(!routes.content.contains("use crate::controllers::auth;"));
312
313 let user = files
314 .iter()
315 .find(|f| f.path == "app/models/user.rs")
316 .expect("user model");
317 assert!(user.content.contains("impl AuthUser for Model"));
318 }
319
320 #[test]
321 fn install_does_not_copy_controllers_or_views() {
322 for args in [&[][..], &["--api"][..], &["--two-factor"][..]] {
323 let files = AuthInstallGenerator.generate(args).unwrap();
324 assert!(
325 !files
326 .iter()
327 .any(|f| f.path.contains("app/controllers/auth/")),
328 "auth:install must not copy controllers (args {args:?})"
329 );
330 assert!(
331 !files.iter().any(|f| f.path.contains("app/views/auth/")),
332 "auth:install must not copy views (args {args:?})"
333 );
334 }
335 }
336
337 #[test]
338 fn two_factor_adds_migration_columns() {
339 let files = AuthInstallGenerator.generate(&["--two-factor"]).unwrap();
340 let migration = files
341 .iter()
342 .find(|f| f.path.contains("create_users_table"))
343 .unwrap();
344 assert!(migration.content.contains("two_factor_secret"));
345 assert!(migration.content.contains("two_factor_enabled"));
346 }
347
348 #[test]
349 fn default_install_writes_module_list_to_config() {
350 let modules = crate::config::AuthConfig::default().modules;
352 let section = auth_section(&modules);
353 assert!(section.contains("modules:"));
354 assert!(section.contains("- database_authenticatable"));
355 assert!(section.contains("- registerable"));
356 assert!(section.contains("- recoverable"));
357 assert!(section.contains("- rememberable"));
358 assert!(section.contains("- validatable"));
359 }
360
361 #[test]
362 fn explicit_modules_generate_only_route_list() {
363 let cfg = crate::config::AuthConfig {
366 modules: selected_modules(&[
367 "--modules=database_authenticatable,trackable,lockable,confirmable",
368 ])
369 .0,
370 ..Default::default()
371 };
372 let base = "use crate::controllers::HelloController;\n\
373 use doido::controller::{axum, routes};\n\n\
374 pub fn router() -> axum::Router {\n routes! {\n get!(\"/\", HelloController::index);\n }\n}\n";
375 let routes = crate::generators::route_injector::inject_auth_routes_only(
376 base,
377 &cfg.enabled_route_groups(),
378 );
379 assert!(routes.contains("auth_routes!(User, only: ["));
380 assert!(routes.contains("sessions"));
381 assert!(routes.contains("confirmation"));
382 assert!(routes.contains("unlock"));
383 assert!(!routes.contains("registrations"));
385 assert!(!routes.contains("passwords"));
386 }
387
388 #[test]
389 fn explicit_modules_generate_columns_and_entity_fields() {
390 let files = AuthInstallGenerator
391 .generate(&["--modules=database_authenticatable,trackable,lockable,confirmable"])
392 .unwrap();
393
394 let migration = files
395 .iter()
396 .find(|f| f.path.contains("create_users_table"))
397 .unwrap();
398 assert!(migration.content.contains("sign_in_count"));
399 assert!(migration.content.contains("failed_attempts"));
400 assert!(migration.content.contains("confirmation_token"));
401
402 let entity = files
403 .iter()
404 .find(|f| f.path == "app/models/_entities/users.rs")
405 .unwrap();
406 assert!(entity.content.contains("pub sign_in_count: i32,"));
407 assert!(entity.content.contains("pub failed_attempts: i32,"));
408 assert!(entity
409 .content
410 .contains("pub confirmation_token: Option<String>,"));
411 assert!(!entity.content.contains("{module_fields}"));
413 }
414}