Skip to main content

doido_auth/generators/
install.rs

1//! `doido generate auth:install` — the `devise:install` + `devise User` analogue.
2//!
3//! Emits a User migration + model, an `auth:` config snippet, and injects a bare
4//! `auth_routes!(User);` into `config/routes.rs` that targets doido-auth's
5//! **built-in** controllers. It does **not** copy any auth controllers or views
6//! into the app (run `doido generate auth:controllers` to eject those for
7//! customization) and does **not** modify `Cargo.toml`.
8
9use 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
26/// Migration column statements contributed by `module`, one `t.<type>(...)…;`
27/// per line (indented for the `create_table` closure body). Behavior-only and
28/// base modules contribute nothing here.
29fn 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
62/// SeaORM entity struct fields contributed by `module` (matches the migration
63/// columns above). Emitted into `_entities/users.rs` so the app compiles before
64/// the first `db migrate` (which then regenerates the entity from the schema).
65fn 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
171/// The module set selected for this install: `--modules=a,b,c` when given (with
172/// `database_authenticatable` always ensured), otherwise the default set;
173/// `--two-factor` adds `two_factor_authenticatable`.
174fn 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        // Routes target the framework's built-in controllers (nothing copied —
225        // run `auth:controllers` to eject). A default install mounts every module
226        // route (bare `auth_routes!(User);`); an explicit `--modules=` selection
227        // restricts the mounted groups via `only:`.
228        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        // Bare route targeting the framework's built-in controllers.
302        assert!(routes.content.contains("auth_routes!(User);"));
303        assert!(routes.content.contains("doido::auth::routes!"));
304        // `auth_routes!(User)` expands to `AuthSessions::<User>` — the User model
305        // must be brought into scope.
306        assert!(routes
307            .content
308            .contains("use crate::models::user::Model as User;"));
309        // No local controllers are referenced — nothing was copied into the app.
310        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        // config_file reads existing config off disk; test auth_section directly.
351        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        // Build the routes form directly (deterministic; `generate()` reads the
364        // cwd's config/routes.rs which parallel tests may have written).
365        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        // recoverable / registerable not selected — their groups are absent.
384        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        // No leftover template placeholder.
412        assert!(!entity.content.contains("{module_fields}"));
413    }
414}