Skip to main content

auth_framework/cli/
mod.rs

1/// Minimal CLI progress bar using terminal output.
2/// For richer progress bars, consider the `indicatif` crate.
3///
4/// The CLI delegates database migrations to `MigrationCli` and uses the core
5/// `AuthFramework` APIs for user, role, status, health, audit, and session management.
6/// Destructive database maintenance flows are implemented as logical snapshot
7/// export/import operations backed by the maintenance module.
8pub struct CliProgressBar {}
9
10impl CliProgressBar {
11    pub fn new(msg: &str) -> Self {
12        // Example: print message for progress bar init
13        println!("[ProgressBar] Starting: {}", msg);
14        Self {}
15    }
16    pub fn set_progress(&self, percent: u64) {
17        // Example: print progress update
18        println!("[ProgressBar] Progress: {}%", percent);
19    }
20    pub fn finish(&self) {
21        // Example: print finish message
22        println!("[ProgressBar] Finished");
23    }
24}
25
26pub fn format_cli_output(msg: &str) -> String {
27    // Example: blue bold formatting
28    format!("\x1b[1;34m[auth-framework]\x1b[0m {}", msg)
29}
30#[cfg(feature = "cli")]
31use crate::AppConfig;
32#[cfg(feature = "cli")]
33use crate::auth_operations::UserListQuery;
34#[cfg(feature = "cli")]
35use crate::migrations::MigrationCli;
36#[cfg(feature = "cli")]
37use crate::permissions::{Permission, Role};
38#[cfg(feature = "cli")]
39use clap::{Parser, Subcommand};
40#[cfg(feature = "cli")]
41use std::{io, process};
42
43#[cfg(feature = "cli")]
44#[derive(Parser)]
45#[command(name = "auth-framework")]
46#[command(about = "Auth Framework CLI - Manage authentication and authorization")]
47pub struct Cli {
48    #[command(subcommand)]
49    pub command: Option<Commands>,
50
51    #[arg(short, long, default_value = "auth.toml")]
52    pub config: String,
53
54    #[arg(long)]
55    pub verbose: bool,
56
57    #[arg(short, long)]
58    pub dry_run: bool,
59}
60
61#[cfg(feature = "cli")]
62#[derive(Subcommand)]
63pub enum Commands {
64    /// Database operations
65    Db {
66        #[command(subcommand)]
67        command: DbCommands,
68    },
69    /// User management
70    User {
71        #[command(subcommand)]
72        command: UserCommands,
73    },
74    /// Role and permission management
75    Role {
76        #[command(subcommand)]
77        command: RoleCommands,
78    },
79    /// System administration
80    System {
81        #[command(subcommand)]
82        command: SystemCommands,
83    },
84    /// Security operations
85    Security {
86        #[command(subcommand)]
87        command: SecurityCommands,
88    },
89}
90
91#[cfg(feature = "cli")]
92#[derive(Subcommand)]
93pub enum DbCommands {
94    /// Run database migrations
95    Migrate,
96    /// Show migration status
97    Status,
98    /// Reset database (WARNING: destructive)
99    Reset {
100        #[arg(long)]
101        confirm: bool,
102    },
103    /// Create a new migration file
104    CreateMigration { name: String },
105}
106
107#[cfg(feature = "cli")]
108#[derive(Subcommand)]
109pub enum UserCommands {
110    /// List users
111    List {
112        #[arg(short, long)]
113        limit: Option<usize>,
114        #[arg(short, long)]
115        offset: Option<usize>,
116        #[arg(long)]
117        active_only: bool,
118    },
119    /// Create a new user
120    Create {
121        email: String,
122        #[arg(short, long)]
123        username: Option<String>,
124        #[arg(short, long)]
125        password: Option<String>,
126        #[arg(long)]
127        admin: bool,
128    },
129    /// Update user
130    Update {
131        user_id: String,
132        #[arg(short, long)]
133        email: Option<String>,
134        #[arg(short, long)]
135        active: Option<bool>,
136    },
137    /// Delete user
138    Delete {
139        user_id: String,
140        #[arg(long)]
141        confirm: bool,
142    },
143    /// Reset user password
144    ResetPassword {
145        user_id: String,
146        #[arg(short, long)]
147        password: Option<String>,
148    },
149    /// Show user details
150    Show { user_id: String },
151}
152
153#[cfg(feature = "cli")]
154#[derive(Subcommand)]
155pub enum RoleCommands {
156    /// List roles
157    List,
158    /// Create role
159    Create {
160        name: String,
161        #[arg(short, long)]
162        description: Option<String>,
163    },
164    /// Assign role to user
165    Assign { user_id: String, role_name: String },
166    /// Remove role from user
167    Remove { user_id: String, role_name: String },
168    /// List permissions for role
169    Permissions { role_name: String },
170    /// Add permission to role
171    AddPermission {
172        role_name: String,
173        permission: String,
174    },
175}
176
177#[cfg(feature = "cli")]
178#[derive(Subcommand)]
179pub enum SystemCommands {
180    /// Show system status
181    Status,
182    /// Health check
183    Health,
184    /// Generate configuration template
185    Config {
186        #[arg(short, long)]
187        output: Option<String>,
188    },
189    /// Backup system data
190    Backup { output_path: String },
191    /// Restore system data
192    Restore {
193        backup_path: String,
194        #[arg(long)]
195        confirm: bool,
196    },
197}
198
199#[cfg(feature = "cli")]
200#[derive(Subcommand)]
201pub enum SecurityCommands {
202    /// Show security audit
203    Audit {
204        #[arg(short, long)]
205        days: Option<u32>,
206    },
207    /// List active sessions
208    Sessions {
209        #[arg(short, long)]
210        user_id: Option<String>,
211    },
212    /// Terminate session
213    TerminateSession {
214        session_id: String,
215        #[arg(long)]
216        reason: Option<String>,
217    },
218    /// Lock user account
219    LockUser {
220        user_id: String,
221        #[arg(short, long)]
222        reason: Option<String>,
223    },
224    /// Unlock user account
225    UnlockUser { user_id: String },
226}
227
228#[cfg(feature = "cli")]
229pub struct CliHandler {
230    config: AppConfig,
231    dry_run: bool,
232    // storage: Option<PostgresStorage>, // Removed unused field
233}
234
235#[cfg(feature = "cli")]
236impl CliHandler {
237    pub async fn new(config: AppConfig) -> Result<Self, Box<dyn std::error::Error>> {
238        // Removed unused storage variable
239        Ok(Self {
240            config,
241            dry_run: false,
242        })
243    }
244
245    async fn framework(&self) -> Result<crate::AuthFramework, Box<dyn std::error::Error>> {
246        Ok(self.config.build_auth_framework().await?)
247    }
248
249    fn prompt_password(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
250        let password = rpassword::prompt_password(prompt)?;
251        if password.is_empty() {
252            return Err(
253                io::Error::new(io::ErrorKind::InvalidInput, "Password cannot be empty").into(),
254            );
255        }
256        Ok(password)
257    }
258
259    fn default_username_from_email(email: &str) -> String {
260        email
261            .split('@')
262            .next()
263            .filter(|value| !value.is_empty())
264            .unwrap_or("user")
265            .to_string()
266    }
267
268    fn print_user_summary(user: &crate::auth::UserInfo) {
269        let roles = if user.roles.is_empty() {
270            "-".to_string()
271        } else {
272            user.roles.join(", ")
273        };
274        let email = user.email.as_deref().unwrap_or("-");
275        println!(
276            "{}\t{}\t{}\tactive={}\troles={}",
277            user.id, user.username, email, user.active, roles
278        );
279    }
280
281    fn print_role_permissions(role: &Role) {
282        let mut permissions: Vec<String> =
283            role.permissions.iter().map(ToString::to_string).collect();
284        permissions.sort();
285
286        println!("Role: {}", role.name);
287        if let Some(description) = &role.description {
288            println!("Description: {}", description);
289        }
290        println!("Active: {}", role.active);
291        println!("Permissions:");
292        if permissions.is_empty() {
293            println!("  (none)");
294        } else {
295            for permission in permissions {
296                println!("  {}", permission);
297            }
298        }
299    }
300
301    pub async fn handle_command(&mut self, cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
302        self.dry_run = cli.dry_run;
303        match cli.command {
304            Some(Commands::Db { command }) => self.handle_db_command(command).await?,
305            Some(Commands::User { command }) => self.handle_user_command(command).await?,
306            Some(Commands::Role { command }) => self.handle_role_command(command).await?,
307            Some(Commands::System { command }) => self.handle_system_command(command).await?,
308            Some(Commands::Security { command }) => self.handle_security_command(command).await?,
309            None => {
310                eprintln!("No command provided. Use --help for usage.");
311            }
312        }
313        Ok(())
314    }
315
316    async fn handle_db_command(
317        &mut self,
318        command: DbCommands,
319    ) -> Result<(), Box<dyn std::error::Error>> {
320        match command {
321            DbCommands::Migrate => {
322                println!("Running database migrations...");
323                MigrationCli::run(&self.config.database.url, "migrate").await?;
324            }
325            DbCommands::Status => {
326                MigrationCli::run(&self.config.database.url, "status").await?;
327            }
328            DbCommands::Reset { confirm } => {
329                if !confirm {
330                    eprintln!("ERROR: Database reset requires --confirm flag");
331                    eprintln!("WARNING: This will destroy all data!");
332                    process::exit(1);
333                }
334                let framework = self.framework().await?;
335                let report = framework.maintenance().reset(self.dry_run).await?;
336                if report.dry_run {
337                    println!(
338                        "Dry run: reset would delete {} users, {} roles, {} tokens, {} sessions, and {} KV entries.",
339                        report.users_deleted,
340                        report.roles_seen,
341                        report.tokens_deleted,
342                        report.sessions_deleted,
343                        report.kv_entries_deleted
344                    );
345                } else {
346                    println!(
347                        "Reset completed: deleted {} users, {} tokens, {} sessions, and {} KV entries.",
348                        report.users_deleted,
349                        report.tokens_deleted,
350                        report.sessions_deleted,
351                        report.kv_entries_deleted
352                    );
353                }
354            }
355            DbCommands::CreateMigration { name } => {
356                let report = crate::maintenance::create_migration_file(&self.config, &name).await?;
357                println!(
358                    "Created {} migration template: {}",
359                    report.backend,
360                    report.path.display()
361                );
362            }
363        }
364        Ok(())
365    }
366
367    async fn handle_user_command(
368        &mut self,
369        command: UserCommands,
370    ) -> Result<(), Box<dyn std::error::Error>> {
371        match command {
372            UserCommands::List {
373                limit,
374                offset,
375                active_only,
376            } => {
377                let framework = self.framework().await?;
378                let mut query = UserListQuery::new();
379                if let Some(l) = limit {
380                    query = query.limit(l);
381                }
382                if let Some(o) = offset {
383                    query = query.offset(o);
384                }
385                if active_only {
386                    query = query.active_only();
387                }
388                let users = framework.users().list_with_query(query).await?;
389                if users.is_empty() {
390                    println!("No users found.");
391                } else {
392                    for user in users {
393                        Self::print_user_summary(&user);
394                    }
395                }
396            }
397            UserCommands::Create {
398                email,
399                username,
400                password,
401                admin,
402            } => {
403                let framework = self.framework().await?;
404                let username =
405                    username.unwrap_or_else(|| Self::default_username_from_email(&email));
406                let password = match password {
407                    Some(password) => password,
408                    None => Self::prompt_password("Password: ")?,
409                };
410                let user_id = framework
411                    .users()
412                    .register(&username, &email, &password)
413                    .await?;
414                if admin {
415                    framework
416                        .authorization()
417                        .assign_role(&user_id, "admin")
418                        .await?;
419                }
420                println!("Created user '{}' with ID {}", username, user_id);
421            }
422            UserCommands::Show { user_id } => {
423                let framework = self.framework().await?;
424                let user = framework.users().get(&user_id).await?;
425                Self::print_user_summary(&user);
426            }
427            UserCommands::Update {
428                user_id,
429                email,
430                active,
431            } => {
432                if email.is_none() && active.is_none() {
433                    return Err(io::Error::new(
434                        io::ErrorKind::InvalidInput,
435                        "Provide --email and/or --active when updating a user",
436                    )
437                    .into());
438                }
439
440                let framework = self.framework().await?;
441                if let Some(email) = email {
442                    framework.users().update_email(&user_id, &email).await?;
443                }
444                if let Some(active) = active {
445                    framework
446                        .users()
447                        .set_status(&user_id, active.into())
448                        .await?;
449                }
450                println!("Updated user {}", user_id);
451            }
452            UserCommands::Delete { user_id, confirm } => {
453                if !confirm {
454                    eprintln!("ERROR: User deletion requires --confirm flag");
455                    eprintln!("WARNING: This will permanently delete the user!");
456                    process::exit(1);
457                }
458                let framework = self.framework().await?;
459                framework.users().delete_by_id(&user_id).await?;
460                println!("Deleted user {}", user_id);
461            }
462            UserCommands::ResetPassword { user_id, password } => {
463                let framework = self.framework().await?;
464                let password = match password {
465                    Some(password) => password,
466                    None => Self::prompt_password("New password: ")?,
467                };
468                framework
469                    .users()
470                    .update_password_by_id(&user_id, &password)
471                    .await?;
472                println!("Password updated for user {}", user_id);
473            }
474        }
475
476        Ok(())
477    }
478
479    async fn handle_role_command(
480        &mut self,
481        command: RoleCommands,
482    ) -> Result<(), Box<dyn std::error::Error>> {
483        match command {
484            RoleCommands::List => {
485                let framework = self.framework().await?;
486                let mut roles = framework.authorization().list_roles().await;
487                roles.sort_by(|left, right| left.name.cmp(&right.name));
488                if roles.is_empty() {
489                    println!("No roles found.");
490                } else {
491                    for role in roles {
492                        println!(
493                            "{}\tactive={}\tpermissions={}",
494                            role.name,
495                            role.active,
496                            role.permissions.len()
497                        );
498                    }
499                }
500            }
501            RoleCommands::AddPermission {
502                role_name,
503                permission,
504            } => {
505                let framework = self.framework().await?;
506                let permission = Permission::parse(&permission)?;
507                framework
508                    .authorization()
509                    .add_role_permission(&role_name, permission)
510                    .await?;
511                println!("Added permission to role {}", role_name);
512            }
513            RoleCommands::Create { name, description } => {
514                let framework = self.framework().await?;
515                let role = match description {
516                    Some(description) => Role::new(&name).with_description(description),
517                    None => Role::new(&name),
518                };
519                framework.authorization().create_role(role).await?;
520                println!("Created role {}", name);
521            }
522            RoleCommands::Assign { user_id, role_name } => {
523                let framework = self.framework().await?;
524                framework
525                    .authorization()
526                    .assign_role(&user_id, &role_name)
527                    .await?;
528                println!("Assigned role {} to {}", role_name, user_id);
529            }
530            RoleCommands::Remove { user_id, role_name } => {
531                let framework = self.framework().await?;
532                framework
533                    .authorization()
534                    .remove_role(&user_id, &role_name)
535                    .await?;
536                println!("Removed role {} from {}", role_name, user_id);
537            }
538            RoleCommands::Permissions { role_name } => {
539                let framework = self.framework().await?;
540                let role = framework.authorization().role(&role_name).await?;
541                Self::print_role_permissions(&role);
542            }
543        }
544
545        Ok(())
546    }
547
548    async fn handle_system_command(
549        &mut self,
550        command: SystemCommands,
551    ) -> Result<(), Box<dyn std::error::Error>> {
552        match command {
553            SystemCommands::Status => {
554                let framework = self.framework().await?;
555                let stats = framework.monitoring().stats().await?;
556                let audit = framework.audit().security_stats().await?;
557                println!(
558                    "Registered methods: {}",
559                    stats.registered_methods.join(", ")
560                );
561                println!("Active sessions: {}", stats.active_sessions);
562                println!("Active MFA challenges: {}", stats.active_mfa_challenges);
563                println!("Authentication attempts: {}", stats.auth_attempts);
564                println!("Security score: {:.2}", audit.security_score());
565            }
566            SystemCommands::Health => {
567                let framework = self.framework().await?;
568                let health = framework.monitoring().health_check().await?;
569                for (component, result) in health {
570                    println!(
571                        "{}\t{:?}\t{}\t{}ms",
572                        component, result.status, result.message, result.response_time
573                    );
574                }
575            }
576            SystemCommands::Config { output } => {
577                let template = include_str!("../config/auth.toml.template");
578                if let Some(path) = output {
579                    std::fs::write(&path, template)?;
580                    println!("Configuration template written to: {}", path);
581                } else {
582                    println!("{}", template);
583                }
584            }
585            SystemCommands::Backup { output_path } => {
586                let framework = self.framework().await?;
587                let report = framework
588                    .maintenance()
589                    .backup_to_file(&output_path, self.dry_run)
590                    .await?;
591                if report.dry_run {
592                    println!(
593                        "Dry run: backup would write {} users, {} roles, {} tokens, {} sessions, and {} KV entries to {}.",
594                        report.manifest.user_count,
595                        report.manifest.role_count,
596                        report.manifest.token_count,
597                        report.manifest.session_count,
598                        report.manifest.kv_entry_count,
599                        report.output_path.display()
600                    );
601                } else {
602                    println!(
603                        "Backup written to {} (users={}, roles={}, tokens={}, sessions={}, kv={}).",
604                        report.output_path.display(),
605                        report.manifest.user_count,
606                        report.manifest.role_count,
607                        report.manifest.token_count,
608                        report.manifest.session_count,
609                        report.manifest.kv_entry_count
610                    );
611                }
612            }
613            SystemCommands::Restore {
614                backup_path,
615                confirm,
616            } => {
617                if !confirm {
618                    eprintln!("ERROR: Database restore requires --confirm flag");
619                    eprintln!("WARNING: This will overwrite existing data!");
620                    process::exit(1);
621                }
622                let framework = self.framework().await?;
623                let report = framework
624                    .maintenance()
625                    .restore_from_file(&backup_path, self.dry_run)
626                    .await?;
627                if report.dry_run {
628                    println!(
629                        "Dry run: restore would apply snapshot from {} (users={}, roles={}, tokens={}, sessions={}, kv={}).",
630                        report.input_path.display(),
631                        report.manifest.user_count,
632                        report.manifest.role_count,
633                        report.manifest.token_count,
634                        report.manifest.session_count,
635                        report.manifest.kv_entry_count
636                    );
637                } else {
638                    println!(
639                        "Restore completed from {} (users={}, roles={}, tokens={}, sessions={}, kv={}).",
640                        report.input_path.display(),
641                        report.manifest.user_count,
642                        report.manifest.role_count,
643                        report.manifest.token_count,
644                        report.manifest.session_count,
645                        report.manifest.kv_entry_count
646                    );
647                }
648            }
649        }
650        Ok(())
651    }
652
653    async fn handle_security_command(
654        &mut self,
655        command: SecurityCommands,
656    ) -> Result<(), Box<dyn std::error::Error>> {
657        match command {
658            SecurityCommands::Audit { days } => {
659                let framework = self.framework().await?;
660                let stats = framework.audit().security_stats().await?;
661                let logs = framework
662                    .audit()
663                    .permission_logs(None, None, None, Some(20))
664                    .await?;
665                println!(
666                    "Security audit summary for the last {} day(s):",
667                    days.unwrap_or(1)
668                );
669                println!("  active_sessions={}", stats.active_sessions);
670                println!("  failed_logins_24h={}", stats.failed_logins_24h);
671                println!("  successful_logins_24h={}", stats.successful_logins_24h);
672                println!("  security_alerts_24h={}", stats.security_alerts_24h);
673                if logs.is_empty() {
674                    println!("Recent permission audit logs: none");
675                } else {
676                    println!("Recent permission audit logs:");
677                    for log in logs {
678                        println!("  {}", log);
679                    }
680                }
681            }
682            SecurityCommands::Sessions { user_id } => {
683                let framework = self.framework().await?;
684                if let Some(user_id) = user_id {
685                    let sessions = framework.sessions().list_for_user(&user_id).await?;
686                    if sessions.is_empty() {
687                        println!("No sessions found for user {}", user_id);
688                    } else {
689                        for session in sessions {
690                            println!(
691                                "{}\tuser={}\texpires={}\tip={}",
692                                session.session_id,
693                                session.user_id,
694                                session.expires_at,
695                                session.ip_address.as_deref().unwrap_or("-")
696                            );
697                        }
698                    }
699                } else {
700                    let users = framework.users().list_with_query(UserListQuery::new()).await?;
701                    let mut found_any = false;
702                    for user in users {
703                        let sessions = framework.sessions().list_for_user(&user.id).await?;
704                        for session in sessions {
705                            found_any = true;
706                            println!(
707                                "{}\tuser={}\texpires={}\tip={}",
708                                session.session_id,
709                                session.user_id,
710                                session.expires_at,
711                                session.ip_address.as_deref().unwrap_or("-")
712                            );
713                        }
714                    }
715                    if !found_any {
716                        println!("No active sessions found.");
717                    }
718                }
719            }
720            SecurityCommands::LockUser { user_id, reason } => {
721                let framework = self.framework().await?;
722                framework
723                    .users()
724                    .set_status(&user_id, crate::auth_operations::UserStatus::Inactive)
725                    .await?;
726                if let Some(reason) = reason {
727                    println!("Locked user {}: {}", user_id, reason);
728                } else {
729                    println!("Locked user {}", user_id);
730                }
731            }
732            SecurityCommands::TerminateSession { session_id, reason } => {
733                let framework = self.framework().await?;
734                framework.sessions().delete(&session_id).await?;
735                if let Some(reason) = reason {
736                    println!("Terminated session {}: {}", session_id, reason);
737                } else {
738                    println!("Terminated session {}", session_id);
739                }
740            }
741            SecurityCommands::UnlockUser { user_id } => {
742                let framework = self.framework().await?;
743                framework
744                    .users()
745                    .set_status(&user_id, crate::auth_operations::UserStatus::Active)
746                    .await?;
747                println!("Unlocked user {}", user_id);
748            }
749        }
750
751        Ok(())
752    }
753}
754
755#[cfg(feature = "cli")]
756/// Entry point for the CLI application
757pub async fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
758    let cli = Cli::parse();
759
760    // Load configuration
761    let config = AppConfig::from_env()?;
762
763    // Initialize handler and run command
764    let mut handler = CliHandler::new(config).await?;
765    handler.handle_command(cli).await?;
766
767    Ok(())
768}
769
770// Place tests at the end of the file to avoid clippy warning
771#[cfg(all(test, feature = "cli"))]
772mod tests {
773    use super::{
774        Cli, CliHandler, CliProgressBar, Commands, DbCommands, SystemCommands, format_cli_output,
775    };
776    use crate::auth_operations::UserListQuery;
777    use crate::config::app_config::AppConfig;
778    use crate::methods::{AuthMethodEnum, JwtMethod};
779    use crate::permissions::Role;
780    use tempfile::tempdir;
781
782    #[test]
783    fn test_progress_bar() {
784        let pb = CliProgressBar::new("Test");
785        pb.set_progress(50);
786        pb.finish();
787    }
788    #[test]
789    fn test_terminal_formatting() {
790        let msg = format_cli_output("Hello");
791        assert!(msg.contains("[auth-framework]"));
792    }
793
794    #[cfg(all(feature = "cli", feature = "sqlite-storage"))]
795    #[tokio::test]
796    async fn maintenance_cli_smoke_test_roundtrip() {
797        let temp_dir = tempdir().unwrap();
798        let db_path = temp_dir.path().join("maintenance-smoke.db");
799        let snapshot_path = temp_dir.path().join("snapshot.json");
800
801        let database_url = format!(
802            "sqlite://{}?mode=rwc",
803            db_path.to_string_lossy().replace('\\', "/")
804        );
805
806        let mut config = AppConfig::default();
807        config.database.url = database_url;
808
809        let mut seed_framework = config.build_auth_framework().await.unwrap();
810        seed_framework.register_method("jwt", AuthMethodEnum::Jwt(JwtMethod::new()));
811
812        let user_id = seed_framework
813            .users()
814            .register("cli-smoke", "cli-smoke@example.com", "Password123!")
815            .await
816            .unwrap();
817        seed_framework
818            .authorization()
819            .create_role(Role::new("operator"))
820            .await
821            .unwrap();
822        seed_framework
823            .authorization()
824            .assign_role(&user_id, "operator")
825            .await
826            .unwrap();
827        seed_framework
828            .tokens()
829            .create(&user_id, &["read"], "jwt", None)
830            .await
831            .unwrap();
832        seed_framework
833            .sessions()
834            .create(
835                &user_id,
836                std::time::Duration::from_secs(600),
837                Some("127.0.0.1".to_string()),
838                Some("cli-smoke".to_string()),
839            )
840            .await
841            .unwrap();
842        seed_framework
843            .storage()
844            .store_kv("smoke:key", b"present", None)
845            .await
846            .unwrap();
847        drop(seed_framework);
848
849        let mut handler = CliHandler::new(config.clone()).await.unwrap();
850        handler
851            .handle_command(Cli {
852                command: Some(Commands::System {
853                    command: SystemCommands::Backup {
854                        output_path: snapshot_path.to_string_lossy().to_string(),
855                    },
856                }),
857                config: "auth.toml".to_string(),
858                verbose: false,
859                dry_run: false,
860            })
861            .await
862            .unwrap();
863        assert!(snapshot_path.exists());
864
865        handler
866            .handle_command(Cli {
867                command: Some(Commands::Db {
868                    command: DbCommands::Reset { confirm: true },
869                }),
870                config: "auth.toml".to_string(),
871                verbose: false,
872                dry_run: false,
873            })
874            .await
875            .unwrap();
876
877        let reset_framework = config.build_auth_framework().await.unwrap();
878        assert!(
879            reset_framework
880                .users()
881                .list_with_query(UserListQuery::new())
882                .await
883                .unwrap()
884                .is_empty()
885        );
886        assert!(
887            reset_framework
888                .storage()
889                .get_kv("smoke:key")
890                .await
891                .unwrap()
892                .is_none()
893        );
894        drop(reset_framework);
895
896        handler
897            .handle_command(Cli {
898                command: Some(Commands::System {
899                    command: SystemCommands::Restore {
900                        backup_path: snapshot_path.to_string_lossy().to_string(),
901                        confirm: true,
902                    },
903                }),
904                config: "auth.toml".to_string(),
905                verbose: false,
906                dry_run: false,
907            })
908            .await
909            .unwrap();
910
911        let restored_framework = config.build_auth_framework().await.unwrap();
912        let restored_user = restored_framework.users().get(&user_id).await.unwrap();
913        assert_eq!(restored_user.username, "cli-smoke");
914        assert_eq!(
915            restored_framework
916                .tokens()
917                .list_for_user(&user_id)
918                .await
919                .unwrap()
920                .len(),
921            1
922        );
923        assert_eq!(
924            restored_framework
925                .sessions()
926                .list_for_user(&user_id)
927                .await
928                .unwrap()
929                .len(),
930            1
931        );
932        assert!(
933            restored_framework
934                .authorization()
935                .has_role(&user_id, "operator")
936                .await
937                .unwrap()
938        );
939        assert_eq!(
940            restored_framework
941                .storage()
942                .get_kv("smoke:key")
943                .await
944                .unwrap()
945                .unwrap(),
946            b"present"
947        );
948        drop(restored_framework);
949
950        let original_dir = std::env::current_dir().unwrap();
951        std::env::set_current_dir(temp_dir.path()).unwrap();
952        let migration_result = handler
953            .handle_command(Cli {
954                command: Some(Commands::Db {
955                    command: DbCommands::CreateMigration {
956                        name: "smoke test migration".to_string(),
957                    },
958                }),
959                config: "auth.toml".to_string(),
960                verbose: false,
961                dry_run: false,
962            })
963            .await;
964        std::env::set_current_dir(original_dir).unwrap();
965        migration_result.unwrap();
966
967        let migration_dir = temp_dir.path().join("migrations").join("sqlite");
968        let entries = std::fs::read_dir(&migration_dir)
969            .unwrap()
970            .collect::<Result<Vec<_>, _>>()
971            .unwrap();
972        assert_eq!(entries.len(), 1);
973    }
974}