rustauth-cli 0.2.0

Command-line tools for RustAuth.
Documentation
#![allow(clippy::expect_used)]

mod support;

use assert_cmd::Command;
use predicates::prelude::*;
use support::*;

#[test]
fn doctor_warns_on_legacy_router_pattern() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_sqlite_config(temp.path(), "sqlite::memory:", &[]);
    write_minimal_rust_src(
        temp.path(),
        "fn main() { let _ = rustauth_axum::router(()); }",
    );

    rustauth_cmd(temp.path())
        .args(["doctor", "--json"])
        .env("DATABASE_URL", "sqlite::memory:")
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success()
        .stdout(predicate::str::contains("integration.legacy_router"));
}

#[test]
fn doctor_warns_on_double_nest_pattern() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_sqlite_config(temp.path(), "sqlite::memory:", &[]);
    write_minimal_rust_src(
        temp.path(),
        "fn main() { auth.mount_at_base_path(); router.nest(\"/api\", routes); }",
    );

    rustauth_cmd(temp.path())
        .args(["doctor", "--json"])
        .env("DATABASE_URL", "sqlite::memory:")
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success()
        .stdout(predicate::str::contains("integration.double_nest"));
}

#[test]
#[cfg(feature = "sqlx")]
fn doctor_reports_pending_schema_before_migrate() {
    let temp = tempfile::tempdir().expect("tempdir");
    let database_url = sqlite_database_url(temp.path(), "doctor_pending.sqlite");
    write_sqlite_config(temp.path(), &database_url, &[]);

    rustauth_cmd(temp.path())
        .args(["doctor", "--json"])
        .env("DATABASE_URL", &database_url)
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success()
        .stdout(predicate::str::contains("database.pending_schema"));
}

#[test]
#[cfg(feature = "sqlx")]
fn doctor_reports_schema_up_to_date_after_migrate() {
    let temp = tempfile::tempdir().expect("tempdir");
    let database_url = sqlite_database_url(temp.path(), "doctor_current.sqlite");
    write_sqlite_config(temp.path(), &database_url, &[]);

    rustauth_cmd(temp.path())
        .args(["db", "migrate", "--yes"])
        .env("DATABASE_URL", &database_url)
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success();

    rustauth_cmd(temp.path())
        .args(["doctor", "--json"])
        .env("DATABASE_URL", &database_url)
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success()
        .stdout(predicate::str::contains("database.schema"))
        .stdout(predicate::str::contains("database.pending_schema").not());
}

#[test]
fn doctor_production_requires_https_base_url() {
    let temp = tempfile::tempdir().expect("tempdir");
    std::fs::write(
        temp.path().join("rustauth.toml"),
        r#"
[project]
framework = "axum"
base_url = "http://app.example.com/api/auth"
base_path = "/api/auth"
production = true

[database]
adapter = "sqlx"
provider = "sqlite"
url_env = "DATABASE_URL"
migrations_dir = "migrations/rustauth"

[security]
secret_env = "RUSTAUTH_SECRET"

[plugins]
enabled = []
"#,
    )
    .expect("write config");

    rustauth_cmd(temp.path())
        .args(["doctor", "--production", "--json"])
        .env("DATABASE_URL", "sqlite::memory:")
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .failure()
        .stdout(predicate::str::contains("security.base_url_https"));
}

#[test]
fn info_human_output_lists_findings() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_sqlite_config(temp.path(), "sqlite::memory:", &[]);

    rustauth_cmd(temp.path())
        .args(["info"])
        .env("DATABASE_URL", "sqlite::memory:")
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success()
        .stdout(predicate::str::contains("RustAuth info"))
        .stdout(
            predicate::str::contains("[INFO]")
                .or(predicate::str::contains("[WARN]"))
                .or(predicate::str::contains("[ERROR]")),
        );
}

#[test]
#[cfg(feature = "sqlx")]
fn doctor_strict_fails_on_pending_schema() {
    let temp = tempfile::tempdir().expect("tempdir");
    let database_url = sqlite_database_url(temp.path(), "doctor_strict.sqlite");
    write_sqlite_config(temp.path(), &database_url, &[]);

    rustauth_cmd(temp.path())
        .args(["doctor", "--strict"])
        .env("DATABASE_URL", &database_url)
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .failure()
        .stdout(predicate::str::contains("database.pending_schema"));
}

#[test]
fn init_rejects_unknown_plugin() {
    let temp = tempfile::tempdir().expect("tempdir");

    rustauth_cmd(temp.path())
        .args(["init", "--plugins", "not-a-real-plugin", "--yes"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("not an official RustAuth plugin"));
}

#[test]
#[cfg(feature = "plugins")]
fn plugins_add_rejects_unknown_plugin() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_sqlite_config(temp.path(), "sqlite::memory:", &[]);

    rustauth_cmd(temp.path())
        .args(["plugins", "add", "fake-plugin", "--yes"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("not an official RustAuth plugin"));
}

#[test]
#[cfg(feature = "plugins")]
fn plugins_remove_is_idempotent_in_config() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_sqlite_config(temp.path(), "sqlite::memory:", &["username"]);

    for _ in 0..2 {
        rustauth_cmd(temp.path())
            .args(["plugins", "remove", "username", "--yes"])
            .assert()
            .success()
            .stdout(predicate::str::contains(
                "does not generate destructive migrations",
            ));
    }

    let config = std::fs::read_to_string(temp.path().join("rustauth.toml")).expect("config");
    assert!(!config.contains("\"username\""));
}

#[test]
#[cfg(all(feature = "sqlx", feature = "plugins"))]
fn plugins_remove_then_migrate_keeps_tables() {
    let temp = tempfile::tempdir().expect("tempdir");
    let database_url = sqlite_database_url(temp.path(), "plugins_remove.sqlite");
    write_sqlite_config(temp.path(), &database_url, &["api-key"]);

    rustauth_cmd(temp.path())
        .args(["db", "migrate", "--yes"])
        .env("DATABASE_URL", &database_url)
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success();

    rustauth_cmd(temp.path())
        .args(["plugins", "remove", "api-key", "--yes"])
        .env("DATABASE_URL", &database_url)
        .assert()
        .success();

    rustauth_cmd(temp.path())
        .args(["db", "migrate", "--yes"])
        .env("DATABASE_URL", &database_url)
        .env("RUSTAUTH_SECRET", CLI_TEST_SECRET)
        .assert()
        .success()
        .stdout(predicate::str::contains("No migrations needed."));

    assert!(
        tokio::runtime::Runtime::new()
            .expect("runtime")
            .block_on(sqlite_table_exists(&database_url, "api_keys")),
        "plugins remove must not drop existing tables"
    );
}

#[test]
fn schema_print_rejects_unknown_dialect() {
    let temp = tempfile::tempdir().expect("tempdir");

    rustauth_cmd(temp.path())
        .args(["schema", "print", "--dialect", "cockroachdb"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("unsupported dialect"));
}

#[test]
#[cfg(feature = "plugins")]
fn schema_print_json_honors_config_plugins() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_sqlite_config(temp.path(), "sqlite::memory:", &["admin"]);

    rustauth_cmd(temp.path())
        .args(["schema", "print", "--format", "json", "--dialect", "sqlite"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"role\""));
}

#[test]
#[cfg(feature = "sqlx")]
fn db_generate_rejects_output_and_output_dir_together() {
    let temp = tempfile::tempdir().expect("tempdir");
    let database_url = sqlite_database_url(temp.path(), "generate_flags.sqlite");
    write_sqlite_config(temp.path(), &database_url, &[]);

    rustauth_cmd(temp.path())
        .args([
            "db",
            "generate",
            "--from-empty",
            "--output",
            "foo.sql",
            "--output-dir",
            "dir",
            "--yes",
        ])
        .env("DATABASE_URL", &database_url)
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "Use only one of --output or --output-dir",
        ));
}

#[test]
#[cfg(feature = "sqlx")]
fn db_commands_fail_on_malformed_toml() {
    let temp = tempfile::tempdir().expect("tempdir");
    write_invalid_toml(temp.path());

    rustauth_cmd(temp.path())
        .args(["db", "status"])
        .env("DATABASE_URL", "sqlite::memory:")
        .assert()
        .failure()
        .stderr(predicate::str::contains("failed to parse RustAuth config"));
}

#[test]
fn completions_emits_zsh_script() {
    Command::cargo_bin("rustauth")
        .expect("binary")
        .args(["completions", "zsh"])
        .assert()
        .success()
        .stdout(predicate::str::contains("#compdef").or(predicate::str::contains("_rustauth")));
}

#[test]
fn completions_emits_fish_script() {
    Command::cargo_bin("rustauth")
        .expect("binary")
        .args(["completions", "fish"])
        .assert()
        .success()
        .stdout(predicate::str::is_empty().not());
}