diff --git a/AGENTS.md b/AGENTS.md index 1aff4ae..d1b02a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,19 @@ Multi-environment configurations are managed using the `--profile` (`-p`) flag: --- +## ⚙️ Project Configuration File (`kaji.toml` / `.kaji.toml`) + +Project connection defaults and workspace parameters can be declared inside `kaji.toml` or `.kaji.toml` files in the current working directory. The configuration file is parsed at startup and merged with command-line inputs. + +Settings are resolved in the following precedence order: +1. **CLI Flags** (highest) +2. **Profile Configuration** +3. **Environment Variables** +4. **TOML Configuration** +5. **Fallback Defaults** (lowest) + +--- + ## 🔐 Secret Management & Resolution Secrets are managed via the `SecretResolver` trait using three resolution strategies: diff --git a/Cargo.lock b/Cargo.lock index 14c89d8..9fc9a2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1105,6 +1105,7 @@ dependencies = [ "similar", "tempfile", "tokio", + "toml", ] [[package]] @@ -1755,6 +1756,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2004,6 +2014,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2407,6 +2458,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index ad70de8..fb24035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ sanitize-filename = "0.6.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" serde_yaml = { package = "serde_yaml_ng", version = "0.10.0" } +toml = "0.8.20" tokio = { version = "1.49.0", features = ["full"] } similar = "2.7.0" async-trait = "0.1.86" @@ -58,3 +59,6 @@ harness = false [[bench]] name = "models_bench" harness = false + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)', 'cfg(tarpaulin)'] } diff --git a/GEMINI.md b/GEMINI.md index 2de7748..519adc8 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -53,6 +53,21 @@ For any resource `resource.yaml`, `kaji` looks for `resource.{profile}.yaml` and --- +## ⚙️ Project-Level Configuration File (`kaji.toml` / `.kaji.toml`) + +`kaji` supports project-level settings to override defaults and connection variables persistently. + +### Architecture & Pipeline +1. **Schema Definition**: The `Config` struct is defined in [src/args.rs](src/args.rs). It represents optional fields for Keycloak connection credentials, vault parameters, and the workspace folder. +2. **File Lookup**: In [src/lib.rs](src/lib.rs), `load_config_file` checks for: + * A custom config path specified via `--config` CLI flag or `KAJI_CONFIG` env var. + * `kaji.toml` in the current working directory. + * `.kaji.toml` in the current working directory. +3. **Merging Logic**: In `run_app` in `src/lib.rs`, the loaded `Config` is merged into the parsed CLI `Cli` struct. Settings are resolved using a strictly defined precedence logic: + * **CLI Flags** > **Active Profile** > **Environment Variables** > **TOML Configuration** > **Default Fallbacks**. + +--- + ## 🛠️ Adding a New Resource Support To support a new Keycloak resource (e.g., "Event Listeners"): diff --git a/README.md b/README.md index d734531..528c9e5 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,43 @@ When running with `--profile prod`, `kaji` deep-merges the overlay onto the base | `VAULT_ADDR` | HashiCorp Vault URL | | | `VAULT_TOKEN` | HashiCorp Vault Token | | +### Configuration File (`kaji.toml` / `.kaji.toml`) + +`kaji` supports persisting connection and workspace settings in a TOML configuration file. By default, it searches for `kaji.toml` and then `.kaji.toml` in the current working directory. You can also specify a custom configuration file path using the global `--config` flag or the `KAJI_CONFIG` environment variable. + +#### Example: `kaji.toml` +```toml +# Keycloak Server URL +server = "http://localhost:8080" + +# Target realms (if empty, all realms are considered) +realms = ["master", "myrealm"] + +# Admin User +user = "admin" + +# Client ID used for auth +client_id = "kaji-cli" + +# Default environment profile to load +profile = "dev" + +# Workspace directory containing configuration files (defaults to "workspace") +workspace = "kaji-workspace" + +# HashiCorp Vault Settings +vault_addr = "http://vault:8200" +vault_token = "my-vault-token" +``` + +#### Precedence / Priority Rules +When resolving settings, `kaji` merges settings from different sources in the following priority order (highest to lowest): +1. **CLI Flags** (explicitly passed on command line, e.g. `--server` or `--workspace`) +2. **Profile Configuration** (loaded from `profiles/` directory when `--profile` or `profile` is specified) +3. **Environment Variables** (e.g. `KEYCLOAK_URL`, `KEYCLOAK_CLIENT_ID`) +4. **Config File** (`kaji.toml` / `.kaji.toml`) +5. **Fallback Defaults** (e.g., `"admin-cli"` for client ID, `"workspace"` for workspace) + ### Workspace Structure ```text diff --git a/docs/index.html b/docs/index.html index fc10a69..4aeafb5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -154,12 +154,12 @@

Dependency-Aware Reconciliation

-

Environment Profiles & Overlays

-

One base configuration, zero duplication. Override only what differs per environment via YAML deep-merge overlays.

+

Profiles & TOML Config

+

Persist connection credentials, workspace paths, and target environments cleanly. Support for profiles, overlays, and project-wide configuration files.

diff --git a/src/args.rs b/src/args.rs index b3e0709..126fd13 100644 --- a/src/args.rs +++ b/src/args.rs @@ -11,7 +11,7 @@ pub struct Cli { pub command: Commands, /// Keycloak Server URL - #[arg(long, env = "KEYCLOAK_URL", required_unless_present = "profile")] + #[arg(long, env = "KEYCLOAK_URL")] pub server: Option, /// Keycloak Realms to consider. If empty, all realms are considered. @@ -27,8 +27,8 @@ pub struct Cli { pub password: Option, /// Keycloak Client ID (for client credentials grant) - #[arg(long, env = "KEYCLOAK_CLIENT_ID", default_value = "admin-cli")] - pub client_id: String, + #[arg(long, env = "KEYCLOAK_CLIENT_ID")] + pub client_id: Option, /// Keycloak Client Secret (for client credentials grant) #[arg(skip)] @@ -45,6 +45,10 @@ pub struct Cli { /// HashiCorp Vault Token #[arg(long, env = "VAULT_TOKEN")] pub vault_token: Option, + + /// Path to a custom TOML configuration file + #[arg(long, env = "KAJI_CONFIG")] + pub config: Option, } impl fmt::Debug for Cli { @@ -71,13 +75,13 @@ impl fmt::Debug for Cli { } /// List of subcommands supported by `kaji`. -#[derive(Subcommand, Debug)] +#[derive(Subcommand, Debug, Clone)] pub enum Commands { /// Inspect the current Keycloak configuration and dump to files Inspect { /// Workspace directory for configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, /// Skip confirmation prompt when overwriting local files #[arg(long, short = 'y', default_value = "false")] @@ -86,14 +90,14 @@ pub enum Commands { /// Validate the local Keycloak configuration files Validate { /// Workspace directory containing configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, }, /// Apply the local Keycloak configuration to the server Apply { /// Workspace directory containing configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, /// Skip confirmation prompt #[arg(long, short = 'y', default_value = "false")] @@ -106,8 +110,8 @@ pub enum Commands { /// Plan the application of the local Keycloak configuration Plan { /// Workspace directory containing configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, /// Show only changes, suppressing "No changes" messages #[arg(long, short = 'c')] @@ -120,23 +124,44 @@ pub enum Commands { /// Check for drift between local configuration and server Drift { /// Workspace directory containing configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, }, /// Interactive CLI mode to generate local configuration Cli { /// Workspace directory for configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, }, /// Clean the local configuration files Clean { /// Workspace directory containing configuration files - #[arg(long, short = 'w', default_value = "workspace")] - workspace: PathBuf, + #[arg(long, short = 'w')] + workspace: Option, /// Skip confirmation prompt #[arg(long, short = 'y', default_value = "false")] yes: bool, }, } + +/// The schema of `.kaji.toml` / `kaji.toml` configuration file. +#[derive(serde::Deserialize, Debug, Default, Clone)] +pub struct Config { + /// Keycloak Server URL + pub server: Option, + /// Keycloak Realms to steer + pub realms: Option>, + /// Keycloak Admin User + pub user: Option, + /// Keycloak Client ID + pub client_id: Option, + /// Environment Profile Name + pub profile: Option, + /// HashiCorp Vault URL + pub vault_addr: Option, + /// HashiCorp Vault Token + pub vault_token: Option, + /// Workspace directory + pub workspace: Option, +} diff --git a/src/clean.rs b/src/clean.rs index 1370158..f33dc9f 100644 --- a/src/clean.rs +++ b/src/clean.rs @@ -1,9 +1,8 @@ //! Clean module for cleaning local workspace directories. -use crate::utils::ui::{ACTION, ERROR, SUCCESS, WARN}; +use crate::utils::ui::{ACTION, ERROR, SUCCESS, Ui, WARN}; use anyhow::{Context, Result}; use console::style; -use dialoguer::{Confirm, theme::ColorfulTheme}; use std::path::PathBuf; use tokio::fs; @@ -11,7 +10,12 @@ use tokio::fs; /// /// # Errors /// Returns an error if file deletion fails. -pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) -> Result<()> { +pub async fn run( + workspace_dir: PathBuf, + yes: bool, + realms_to_clean: &[String], + ui: &dyn Ui, +) -> Result<()> { if !workspace_dir.exists() { println!( "{} {}", @@ -54,11 +58,7 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) ) }; - if !Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(msg) - .default(false) - .interact()? - { + if !ui.confirm(&msg, false)? { println!("{} {}", ERROR, style("Aborted.").red()); return Ok(()); } @@ -125,6 +125,8 @@ pub async fn run(workspace_dir: PathBuf, yes: bool, realms_to_clean: &[String]) #[cfg(test)] mod tests { use super::*; + use crate::utils::ui::MockUi; + use std::sync::Mutex; use tempfile::tempdir; #[tokio::test] @@ -140,7 +142,14 @@ mod tests { .await .unwrap(); - run(workspace_dir.clone(), true, &[]).await.unwrap(); + let ui = MockUi { + inputs: Mutex::new(vec![]), + confirms: Mutex::new(vec![]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + run(workspace_dir.clone(), true, &[], &ui).await.unwrap(); assert!(workspace_dir.exists()); let mut entries = fs::read_dir(&workspace_dir).await.unwrap(); @@ -155,7 +164,14 @@ mod tests { fs::create_dir(workspace_dir.join("realm1")).await.unwrap(); fs::create_dir(workspace_dir.join("realm2")).await.unwrap(); - run(workspace_dir.clone(), true, &["realm1".to_string()]) + let ui = MockUi { + inputs: Mutex::new(vec![]), + confirms: Mutex::new(vec![]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + run(workspace_dir.clone(), true, &["realm1".to_string()], &ui) .await .unwrap(); @@ -168,17 +184,68 @@ mod tests { async fn test_clean_non_existent_workspace() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().join("non-existent"); + let ui = MockUi { + inputs: Mutex::new(vec![]), + confirms: Mutex::new(vec![]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; // Should not fail, just print a warning - run(workspace_dir, true, &[]).await.unwrap(); + run(workspace_dir, true, &[], &ui).await.unwrap(); } #[tokio::test] async fn test_clean_empty_targets() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); + let ui = MockUi { + inputs: Mutex::new(vec![]), + confirms: Mutex::new(vec![]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; // workspace exists but we specify a realm that doesn't exist - run(workspace_dir, true, &["non-existent-realm".to_string()]) - .await - .unwrap(); + run( + workspace_dir, + true, + &["non-existent-realm".to_string()], + &ui, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_clean_confirm_yes() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + fs::create_dir(workspace_dir.join("realm1")).await.unwrap(); + + let ui = MockUi { + inputs: Mutex::new(vec![]), + confirms: Mutex::new(vec![true]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + run(workspace_dir.clone(), false, &[], &ui).await.unwrap(); + assert!(!workspace_dir.join("realm1").exists()); + } + + #[tokio::test] + async fn test_clean_confirm_abort() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + fs::create_dir(workspace_dir.join("realm1")).await.unwrap(); + + let ui = MockUi { + inputs: Mutex::new(vec![]), + confirms: Mutex::new(vec![false]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + run(workspace_dir.clone(), false, &[], &ui).await.unwrap(); + assert!(workspace_dir.join("realm1").exists()); } } diff --git a/src/client.rs b/src/client.rs index ea6e168..097ff39 100644 --- a/src/client.rs +++ b/src/client.rs @@ -639,30 +639,39 @@ pub trait KeycloakResourceMapping: Sized { } } +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for RealmRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for RoleRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for ClientRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for ClientScopeRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for UserRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for GroupRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for IdentityProviderRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for RequiredActionProviderRepresentation {} +#[cfg(not(tarpaulin_include))] #[async_trait] impl KeycloakResourceMapping for ComponentRepresentation {} diff --git a/src/lib.rs b/src/lib.rs index eb747b4..d27bbf6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,7 @@ pub mod utils; pub mod validate; use anyhow::{Context, Result}; -use args::{Cli, Commands}; +use args::{Cli, Commands, Config}; use client::KeycloakClient; use console::{Emoji, style}; use std::collections::HashMap; @@ -72,6 +72,41 @@ pub async fn load_profile(workspace: &std::path::Path, name: &str) -> Result) -> Result { + let path = if let Some(p) = custom_path { + Some(p.to_path_buf()) + } else { + let cwd = std::env::current_dir()?; + let kaji_toml = cwd.join("kaji.toml"); + if kaji_toml.exists() { + Some(kaji_toml) + } else { + let dot_kaji_toml = cwd.join(".kaji.toml"); + if dot_kaji_toml.exists() { + Some(dot_kaji_toml) + } else { + None + } + } + }; + + if let Some(config_path) = path { + let content = tokio::fs::read_to_string(&config_path) + .await + .with_context(|| format!("Failed to read config file: {:?}", config_path))?; + let config: Config = toml::from_str(&content) + .with_context(|| format!("Failed to parse config file: {:?}", config_path))?; + Ok(config) + } else { + Ok(Config::default()) + } +} + /// Initializes a `KeycloakClient` by logging in using credentials from the CLI or active profile. /// /// # Errors @@ -84,7 +119,8 @@ pub async fn init_client(cli: &Cli, profile: Option<&Profile>) -> Result Resu .cyan() .bold() ); - clean::run(workspace.to_path_buf(), yes, &cli.realms).await?; + clean::run( + workspace.to_path_buf(), + yes, + &cli.realms, + &crate::utils::ui::DialoguerUi::new(), + ) + .await?; Ok(()) } @@ -313,58 +355,92 @@ async fn handle_clean(cli: &Cli, workspace: &std::path::Path, yes: bool) -> Resu /// # Errors /// Returns an error if command execution or network request fails. pub async fn run_app(cli: Cli) -> Result<()> { - let workspace = match &cli.command { - Commands::Inspect { workspace, .. } => workspace, - Commands::Validate { workspace } => workspace, - Commands::Apply { workspace, .. } => workspace, - Commands::Plan { workspace, .. } => workspace, - Commands::Drift { workspace } => workspace, - Commands::Cli { workspace } => workspace, - Commands::Clean { workspace, .. } => workspace, + // 1. Load configuration file + let config = load_config_file(cli.config.as_deref()).await?; + + // 2. Merge config file settings into Cli + let mut cli = cli; + if cli.server.is_none() { + cli.server = config.server.clone(); + } + if cli.realms.is_empty() && config.realms.is_some() { + cli.realms = config.realms.clone().unwrap(); + } + if cli.user.is_none() { + cli.user = config.user.clone(); + } + if cli.client_id.is_none() { + cli.client_id = config.client_id.clone(); + } + if cli.profile.is_none() { + cli.profile = config.profile.clone(); + } + if cli.vault_addr.is_none() { + cli.vault_addr = config.vault_addr.clone(); + } + if cli.vault_token.is_none() { + cli.vault_token = config.vault_token.clone(); + } + + // 3. Fallback default for client_id + if cli.client_id.is_none() { + cli.client_id = Some("admin-cli".to_string()); + } + + // 4. Resolve workspace directory + let raw_workspace = match &cli.command { + Commands::Inspect { workspace, .. } => workspace.clone(), + Commands::Validate { workspace } => workspace.clone(), + Commands::Apply { workspace, .. } => workspace.clone(), + Commands::Plan { workspace, .. } => workspace.clone(), + Commands::Drift { workspace } => workspace.clone(), + Commands::Cli { workspace } => workspace.clone(), + Commands::Clean { workspace, .. } => workspace.clone(), }; + let workspace = raw_workspace + .or(config.workspace.clone()) + .unwrap_or_else(|| std::path::PathBuf::from("workspace")); + // 5. Load profile if requested let profile = if let Some(p) = &cli.profile { - Some(load_profile(workspace, p).await?) + Some(load_profile(&workspace, p).await?) } else { None }; + // 6. Execute subcommand handlers match &cli.command { - Commands::Inspect { workspace, yes } => { - handle_inspect(&cli, profile.as_ref(), workspace, *yes).await?; + Commands::Inspect { yes, .. } => { + handle_inspect(&cli, profile.as_ref(), &workspace, *yes).await?; } - Commands::Validate { workspace } => { - handle_validate(&cli, workspace).await?; + Commands::Validate { .. } => { + handle_validate(&cli, &workspace).await?; } - Commands::Apply { - workspace, - yes, - review, - } => { - handle_apply(&cli, profile.as_ref(), workspace, *yes, *review).await?; + Commands::Apply { yes, review, .. } => { + handle_apply(&cli, profile.as_ref(), &workspace, *yes, *review).await?; } Commands::Plan { - workspace, changes_only, interactive, + .. } => { handle_plan( &cli, profile.as_ref(), - workspace, + &workspace, *changes_only, *interactive, ) .await?; } - Commands::Drift { workspace } => { - handle_drift(&cli, profile.as_ref(), workspace).await?; + Commands::Drift { .. } => { + handle_drift(&cli, profile.as_ref(), &workspace).await?; } - Commands::Cli { workspace } => { - handle_cli(workspace).await?; + Commands::Cli { .. } => { + handle_cli(&workspace).await?; } - Commands::Clean { workspace, yes } => { - handle_clean(&cli, workspace, *yes).await?; + Commands::Clean { yes, .. } => { + handle_clean(&cli, &workspace, *yes).await?; } } @@ -422,4 +498,60 @@ client_id: "test-client" let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("Failed to parse profile file:")); } + + #[tokio::test] + async fn test_load_config_file_success() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("kaji.toml"); + let toml_content = r#" +server = "http://localhost:8080" +realms = ["master"] +client_id = "test-client-id" +workspace = "test-ws" +"#; + std::fs::write(&config_path, toml_content).unwrap(); + + let config = load_config_file(Some(&config_path)).await.unwrap(); + assert_eq!(config.server, Some("http://localhost:8080".to_string())); + assert_eq!(config.realms, Some(vec!["master".to_string()])); + assert_eq!(config.client_id, Some("test-client-id".to_string())); + assert_eq!(config.workspace, Some(std::path::PathBuf::from("test-ws"))); + } + + #[tokio::test] + async fn test_load_config_file_missing() { + let config = load_config_file(None).await.unwrap(); + assert!(config.server.is_none()); + assert!(config.realms.is_none()); + } + + #[tokio::test] + async fn test_load_config_file_explicit_missing_error() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("non_existent.toml"); + let result = load_config_file(Some(&config_path)).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to read config file:") + ); + } + + #[tokio::test] + async fn test_load_config_file_invalid() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("invalid.toml"); + std::fs::write(&config_path, "server = [invalid").unwrap(); + + let result = load_config_file(Some(&config_path)).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to parse config file:") + ); + } } diff --git a/src/main.rs b/src/main.rs index 246c4b5..c49a1b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::Parser; use kaji::args::Cli; +#[cfg(not(tarpaulin_include))] #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().ok(); diff --git a/src/utils.rs b/src/utils.rs index 46f0168..64a6060 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -379,6 +379,18 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_to_sorted_yaml_error() { + struct FailingStruct; + impl serde::Serialize for FailingStruct { + fn serialize(&self, _serializer: S) -> Result { + Err(serde::ser::Error::custom("Simulated serialization error")) + } + } + let result = to_sorted_yaml(&FailingStruct); + assert!(result.is_err()); + } + #[test] fn test_to_sorted_yaml_simple() -> anyhow::Result<()> { let val = serde_json::json!({ "b": 2, "a": 1 }); diff --git a/src/utils/secrets/vault.rs b/src/utils/secrets/vault.rs index 116c059..5775c1e 100644 --- a/src/utils/secrets/vault.rs +++ b/src/utils/secrets/vault.rs @@ -223,4 +223,70 @@ mod tests { assert!(e.to_string().contains("relative URL without a base")); } } + + #[tokio::test] + async fn test_vault_resolver_non_string_field() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/v1/secret/data/mysecret") + .match_header("X-Vault-Token", "mock-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "data": { + "data": { + "port": 8080 + } + } + }) + .to_string(), + ) + .create_async() + .await; + + let resolver = VaultResolver::new(&server.url(), "mock-token").unwrap(); + let res = resolver + .resolve("vault:secret/mysecret#port") + .await + .unwrap(); + + assert_eq!(res, Some("8080".to_string())); + mock.assert_async().await; + } + + #[tokio::test] + async fn test_vault_resolver_missing_field() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/v1/secret/data/mysecret") + .match_header("X-Vault-Token", "mock-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "data": { + "data": { + "password": "supersecret" + } + } + }) + .to_string(), + ) + .create_async() + .await; + + let resolver = VaultResolver::new(&server.url(), "mock-token").unwrap(); + let res = resolver + .resolve("vault:secret/mysecret#missing_field") + .await; + + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Field 'missing_field' not found") + ); + mock.assert_async().await; + } } diff --git a/src/utils/ui.rs b/src/utils/ui.rs index 4ed1c86..bad496b 100644 --- a/src/utils/ui.rs +++ b/src/utils/ui.rs @@ -97,6 +97,7 @@ impl Default for DialoguerUi { } } +#[cfg(not(tarpaulin_include))] impl Ui for DialoguerUi { fn input(&self, prompt: &str, default: Option, allow_empty: bool) -> Result { let input = dialoguer::Input::::new() diff --git a/src/utils/yaml.rs b/src/utils/yaml.rs index 001cf3c..946b8ca 100644 --- a/src/utils/yaml.rs +++ b/src/utils/yaml.rs @@ -181,4 +181,22 @@ mod tests { assert!(!is_overlay_file(Path::new(".."), Some("prod"))); assert!(!is_overlay_file(Path::new(""), Some("prod"))); } + + #[tokio::test] + async fn test_load_yaml_with_invalid_overlay() { + let dir = tempdir().unwrap(); + let base_path = dir.path().join("resource.yaml"); + fs::write(&base_path, "name: base").unwrap(); + + let overlay_path = dir.path().join("resource.prod.yaml"); + fs::write(&overlay_path, "invalid yaml: { :").unwrap(); + + let res = load_yaml_with_overlay(&base_path, Some("prod")).await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Failed to parse overlay YAML file") + ); + } } diff --git a/tests/apply_test.rs b/tests/apply_test.rs index 299d42b..19daa6a 100644 --- a/tests/apply_test.rs +++ b/tests/apply_test.rs @@ -498,3 +498,204 @@ async fn test_apply() { "All confirms should be consumed during review apply" ); } + +#[tokio::test] +async fn test_apply_aborted_by_user() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client.set_token("mock_token".to_string()); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("test-realm"); + std::fs::create_dir_all(&realm_dir).unwrap(); + + // Write empty .kajiplan + let plan_file = workspace_dir.join(".kajiplan"); + std::fs::write(&plan_file, "[]").unwrap(); + + let ui = Arc::new(kaji::utils::ui::MockUi { + inputs: std::sync::Mutex::new(Vec::new()), + confirms: std::sync::Mutex::new(vec![false]), // User aborts when prompted to apply everything + selects: std::sync::Mutex::new(Vec::new()), + passwords: std::sync::Mutex::new(Vec::new()), + }); + let resolver: Arc = Arc::new(EnvResolver::new(HashMap::new())); + + let res = apply::run( + &client, + workspace_dir.clone(), + &["test-realm".to_string()], + false, // yes = false + false, // review = false + ui.clone(), + resolver, + None, + ) + .await; + + assert!(res.is_ok()); + // Since it aborted, the plan_file should NOT be deleted + assert!(plan_file.exists()); +} + +#[tokio::test] +async fn test_apply_component_no_id_no_name() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client.set_token("mock_token".to_string()); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("test-realm"); + std::fs::create_dir_all(realm_dir.join("components")).unwrap(); + + // Component without name and without id (only providerId) + std::fs::write( + realm_dir.join("components").join("empty.yaml"), + "providerId: ldap\nproviderType: org.keycloak.storage.UserStorageProvider\n", + ) + .unwrap(); + + let ui = Arc::new(kaji::utils::ui::MockUi { + inputs: std::sync::Mutex::new(Vec::new()), + confirms: std::sync::Mutex::new(Vec::new()), + selects: std::sync::Mutex::new(Vec::new()), + passwords: std::sync::Mutex::new(Vec::new()), + }); + let resolver: Arc = Arc::new(EnvResolver::new(HashMap::new())); + + let res = apply::run( + &client, + workspace_dir.clone(), + &["test-realm".to_string()], + true, // yes = true + false, + ui.clone(), + resolver, + None, + ) + .await; + + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_apply_authenticator_configs_review() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client.set_token("mock_token".to_string()); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let configs_dir = workspace_dir.join("authenticator-configs"); + std::fs::create_dir_all(&configs_dir).unwrap(); + + // Create a local config file representing an update to an existing config + let config_update = kaji::models::AuthenticatorConfigRepresentation { + id: Some("config-1".to_string()), + alias: Some("review profile config".to_string()), + config: Some(HashMap::from([( + "loa-condition-level".to_string(), + serde_json::json!("5"), + )])), + extra: HashMap::new(), + }; + std::fs::write( + configs_dir.join("config-update.yaml"), + serde_yaml::to_string(&config_update).unwrap(), + ) + .unwrap(); + + // Create a local config file representing a new config + let config_new = kaji::models::AuthenticatorConfigRepresentation { + id: None, + alias: Some("new config".to_string()), + config: Some(HashMap::from([( + "param".to_string(), + serde_json::json!("val"), + )])), + extra: HashMap::new(), + }; + std::fs::write( + configs_dir.join("config-new.yaml"), + serde_yaml::to_string(&config_new).unwrap(), + ) + .unwrap(); + + // We also need local authentication flow execution referencing "new config" + let flows_dir = workspace_dir.join("authentication-flows"); + std::fs::create_dir_all(&flows_dir).unwrap(); + let flow = kaji::models::AuthenticationFlowRepresentation { + id: Some("f1".to_string()), + alias: Some("flow-1".to_string()), + description: None, + provider_id: Some("basic-flow".to_string()), + top_level: Some(true), + built_in: Some(false), + authentication_executions: Some(vec![ + kaji::models::AuthenticationExecutionExportRepresentation { + id: Some("exec-1".to_string()), + authenticator: Some("review-profile".to_string()), + authenticator_config: Some("new config".to_string()), + requirement: Some("REQUIRED".to_string()), + priority: Some(1), + user_setup_allowed: None, + authenticator_flow: None, + flow_alias: None, + extra: HashMap::new(), + }, + ]), + extra: HashMap::new(), + }; + std::fs::write( + flows_dir.join("flow-1.yaml"), + serde_yaml::to_string(&flow).unwrap(), + ) + .unwrap(); + + // 1. Run review mode where we reject both update and create + let ui_reject = Arc::new(kaji::utils::ui::MockUi { + inputs: std::sync::Mutex::new(Vec::new()), + confirms: std::sync::Mutex::new(vec![false, false]), // Reject first, reject second + selects: std::sync::Mutex::new(Vec::new()), + passwords: std::sync::Mutex::new(Vec::new()), + }); + let resolver: Arc = Arc::new(EnvResolver::new(HashMap::new())); + + let res = apply::authenticator_config::apply_authenticator_configs( + &client, + &workspace_dir, + resolver.clone(), + Arc::new(None), + "test-realm", + None, + true, // review = true + ui_reject.clone(), + ) + .await; + assert!(res.is_ok()); + + // 2. Run review mode where we accept both update and create + let ui_accept = Arc::new(kaji::utils::ui::MockUi { + inputs: std::sync::Mutex::new(Vec::new()), + confirms: std::sync::Mutex::new(vec![true, true]), // Accept first, accept second + selects: std::sync::Mutex::new(Vec::new()), + passwords: std::sync::Mutex::new(Vec::new()), + }); + let res2 = apply::authenticator_config::apply_authenticator_configs( + &client, + &workspace_dir, + resolver.clone(), + Arc::new(None), + "test-realm", + None, + true, // review = true + ui_accept.clone(), + ) + .await; + assert!(res2.is_ok()); +} diff --git a/tests/args_debug_test.rs b/tests/args_debug_test.rs index b3c424b..d4edd77 100644 --- a/tests/args_debug_test.rs +++ b/tests/args_debug_test.rs @@ -4,18 +4,19 @@ use kaji::args::Cli; fn test_cli_debug_obfuscation() { let cli = Cli { command: kaji::args::Commands::Clean { - workspace: std::path::PathBuf::from("workspace"), + workspace: Some(std::path::PathBuf::from("workspace")), yes: false, }, server: Some("http://localhost:8080".to_string()), realms: vec![], user: Some("admin".to_string()), password: Some("secret123".to_string()), - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: Some("secret_client".to_string()), profile: None, vault_addr: None, vault_token: Some("secret_vault".to_string()), + config: None, }; let debug_str = format!("{:?}", cli); diff --git a/tests/cli_interactive_test.rs b/tests/cli_interactive_test.rs index 91204d9..4b2d8b8 100644 --- a/tests/cli_interactive_test.rs +++ b/tests/cli_interactive_test.rs @@ -256,3 +256,127 @@ async fn test_rotate_keys_interactive() { .await .unwrap(); } + +#[tokio::test] +async fn test_rotate_keys_interactive_no_keys() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + + let ui = MockUi { + inputs: Mutex::new(vec!["master".to_string()]), + confirms: Mutex::new(vec![]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + cli::keys::rotate_keys_interactive(&workspace_dir, &ui) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_create_role_interactive_empty_desc_and_realm_role() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + + let ui = MockUi { + inputs: Mutex::new(vec![ + "master".to_string(), + "realm-role".to_string(), + "".to_string(), // Empty description + ]), + confirms: Mutex::new(vec![false]), // Not a client role + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + cli::role::create_role_interactive(&workspace_dir, &ui) + .await + .unwrap(); + assert!( + workspace_dir + .join("master") + .join("roles") + .join("realm-role.yaml") + .exists() + ); +} + +#[tokio::test] +async fn test_create_user_interactive_empty_names() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + + let ui = MockUi { + inputs: Mutex::new(vec![ + "master".to_string(), + "nonameuser".to_string(), + "".to_string(), // Empty email + "".to_string(), // Empty first name + "".to_string(), // Empty last name + ]), + confirms: Mutex::new(vec![]), + selects: Mutex::new(vec![]), + passwords: Mutex::new(vec![]), + }; + + cli::user::create_user_interactive(&workspace_dir, &ui) + .await + .unwrap(); + assert!( + workspace_dir + .join("master") + .join("users") + .join("nonameuser.yaml") + .exists() + ); +} + +#[tokio::test] +async fn test_cli_run_full_loop() { + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + + let ui = MockUi { + inputs: Mutex::new(vec![ + // 0. Create User + "master".to_string(), + "newuser".to_string(), + "user@example.com".to_string(), + "John".to_string(), + "Doe".to_string(), + // 1. Change Password + "master".to_string(), + "newuser".to_string(), + // 2. Create Client + "master".to_string(), + "newclient".to_string(), + // 3. Create Role + "master".to_string(), + "newrole".to_string(), + "desc".to_string(), + "newclient".to_string(), + // 4. Create Group + "master".to_string(), + "newgroup".to_string(), + // 5. Create Identity Provider + "master".to_string(), + "google".to_string(), + "google".to_string(), + // 6. Create Client Scope + "master".to_string(), + "myscope".to_string(), + "openid-connect".to_string(), + // 7. Rotate Keys + "master".to_string(), + ]), + confirms: Mutex::new(vec![ + true, // Create Client: is public? + true, // Create Role: is client role? + ]), + selects: Mutex::new(vec![0, 1, 2, 3, 4, 5, 6, 7, 8]), // Run options 0 to 7 then exit (8) + passwords: Mutex::new(vec!["secret123".to_string()]), + }; + + cli::run(workspace_dir, &ui).await.unwrap(); +} diff --git a/tests/coverage_test.rs b/tests/coverage_test.rs index da40d2e..5c2bee9 100644 --- a/tests/coverage_test.rs +++ b/tests/coverage_test.rs @@ -348,22 +348,39 @@ async fn test_inspect_edge_cases() { async fn test_clean_edge_cases() { let dir = tempdir().unwrap(); let workspace_dir = dir.path().to_path_buf(); + let ui = MockUi { + inputs: std::sync::Mutex::new(vec![]), + confirms: std::sync::Mutex::new(vec![]), + selects: std::sync::Mutex::new(vec![]), + passwords: std::sync::Mutex::new(vec![]), + }; // 1. Test run with non-existent directory - let res = clean::run(workspace_dir.join("non-existent"), true, &[]).await; + let res = clean::run(workspace_dir.join("non-existent"), true, &[], &ui).await; assert!(res.is_ok()); // 2. Test run with realms that don't exist - let res = clean::run(workspace_dir.clone(), true, &["non-existent".to_string()]).await; + let res = clean::run( + workspace_dir.clone(), + true, + &["non-existent".to_string()], + &ui, + ) + .await; assert!(res.is_ok()); // 3. Test cleaning a file instead of a directory fs::create_dir_all(&workspace_dir).unwrap(); let file_path = workspace_dir.join("some_file.txt"); fs::write(&file_path, "test").unwrap(); - clean::run(workspace_dir.clone(), true, &["some_file.txt".to_string()]) - .await - .unwrap(); + clean::run( + workspace_dir.clone(), + true, + &["some_file.txt".to_string()], + &ui, + ) + .await + .unwrap(); assert!(!file_path.exists()); } diff --git a/tests/lib_test.rs b/tests/lib_test.rs index df1aff1..2e42a32 100644 --- a/tests/lib_test.rs +++ b/tests/lib_test.rs @@ -7,17 +7,18 @@ use std::path::PathBuf; async fn test_init_client_fail() { let cli = Cli { server: Some("http://invalid".to_string()), - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: None, user: Some("admin".to_string()), password: Some("password".to_string()), realms: vec![], profile: None, command: Commands::Validate { - workspace: PathBuf::from("."), + workspace: Some(PathBuf::from(".")), }, vault_addr: None, vault_token: None, + config: None, }; let res = init_client(&cli, None).await; @@ -28,17 +29,18 @@ async fn test_init_client_fail() { async fn test_run_app_validate_non_existent() { let cli = Cli { server: Some("http://localhost:8080".to_string()), - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: None, user: None, password: None, realms: vec![], profile: None, command: Commands::Validate { - workspace: PathBuf::from("non-existent-dir-123"), + workspace: Some(PathBuf::from("non-existent-dir-123")), }, vault_addr: None, vault_token: None, + config: None, }; let res = run_app(cli).await; diff --git a/tests/models_debug_test.rs b/tests/models_debug_test.rs index 709c7c5..00ec3d8 100644 --- a/tests/models_debug_test.rs +++ b/tests/models_debug_test.rs @@ -71,3 +71,22 @@ fn test_component_debug() { assert!(!debug_str.contains("other")); // Config is completely redacted now assert!(!debug_str.contains("secret_val")); } + +#[test] +fn test_authenticator_config_debug() { + let mut config = HashMap::new(); + config.insert("mySecretField".to_string(), json!("secret_value")); + + let auth_config = AuthenticatorConfigRepresentation { + id: Some("id1".to_string()), + alias: Some("alias1".to_string()), + config: Some(config), + extra: HashMap::new(), + }; + + let debug_str = format!("{:?}", auth_config); + assert!(debug_str.contains("alias: Some(\"alias1\")")); + assert!(debug_str.contains("config: Some(\"********\")")); + assert!(!debug_str.contains("mySecretField")); + assert!(!debug_str.contains("secret_value")); +} diff --git a/tests/plan_coverage_test.rs b/tests/plan_coverage_test.rs index c1cc5b6..abbde24 100644 --- a/tests/plan_coverage_test.rs +++ b/tests/plan_coverage_test.rs @@ -5,6 +5,7 @@ use kaji::plan; use kaji::utils::secrets::{EnvResolver, SecretResolver}; use std::collections::HashMap; use std::fs; +use std::path::PathBuf; use std::sync::Arc; use tempfile::tempdir; @@ -842,3 +843,165 @@ description: ${KEYCLOAK_ROLE_MISSING_SECRET} .contains("Missing required secret or environment variable") ); } + +#[tokio::test] +async fn test_plan_resources_interactive() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client.set_token("mock".to_string()); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + let roles_dir = realm_dir.join("roles"); + fs::create_dir_all(&roles_dir).unwrap(); + + // Create a new role + let role1 = kaji::models::RoleRepresentation { + id: None, + name: "interactive-role-1".to_string(), + description: None, + container_id: None, + composite: false, + client_role: false, + extra: std::collections::HashMap::new(), + }; + fs::write( + roles_dir.join("role-1.yaml"), + serde_yaml::to_string(&role1).unwrap(), + ) + .unwrap(); + + let role2 = kaji::models::RoleRepresentation { + id: None, + name: "interactive-role-2".to_string(), + description: None, + container_id: None, + composite: false, + client_role: false, + extra: std::collections::HashMap::new(), + }; + fs::write( + roles_dir.join("role-2.yaml"), + serde_yaml::to_string(&role2).unwrap(), + ) + .unwrap(); + + // We have 2 roles. Let's say yes (true) to include the first one, and no (false) to exclude the second one. + let ui = Arc::new(MockUi { + inputs: std::sync::Mutex::new(Vec::new()), + confirms: std::sync::Mutex::new(vec![true, false]), + selects: std::sync::Mutex::new(Vec::new()), + passwords: std::sync::Mutex::new(Vec::new()), + }); + let resolver: Arc = Arc::new(EnvResolver::new(HashMap::new())); + + let res = plan::run( + &client, + workspace_dir.clone(), + false, // changes_only = false + true, // interactive = true + &["test-realm".to_string()], + ui.clone(), + resolver, + None, + ) + .await; + + assert!(res.is_ok()); + + // The plan file should only contain one role (the one we included) + let plan_file = workspace_dir.join(".kajiplan"); + assert!(plan_file.exists()); + let content = fs::read_to_string(&plan_file).unwrap(); + let planned: Vec = serde_json::from_str(&content).unwrap(); + assert_eq!(planned.len(), 1); +} + +#[tokio::test] +async fn test_plan_resources_filter_skips() { + let mock_url = start_mock_server().await; + let mut client = KeycloakClient::new(mock_url); + client.set_target_realm("test-realm".to_string()); + client.set_token("mock".to_string()); + + let dir = tempdir().unwrap(); + let workspace_dir = dir.path().to_path_buf(); + let realm_dir = workspace_dir.join("test-realm"); + fs::create_dir_all(&realm_dir).unwrap(); + + let roles_dir = realm_dir.join("roles"); + fs::create_dir_all(&roles_dir).unwrap(); + + // 1. Role 1 in planned files + let role1 = kaji::models::RoleRepresentation { + id: None, + name: "role-1".to_string(), + description: None, + container_id: None, + composite: false, + client_role: false, + extra: std::collections::HashMap::new(), + }; + let role1_path = roles_dir.join("role-1.yaml"); + fs::write(&role1_path, serde_yaml::to_string(&role1).unwrap()).unwrap(); + + // 2. Role 2 not in planned files + let role2 = kaji::models::RoleRepresentation { + id: None, + name: "role-2".to_string(), + description: None, + container_id: None, + composite: false, + client_role: false, + extra: std::collections::HashMap::new(), + }; + let role2_path = roles_dir.join("role-2.yaml"); + fs::write(&role2_path, serde_yaml::to_string(&role2).unwrap()).unwrap(); + + // 3. Overlay file + let role_overlay_path = roles_dir.join("role-1.prod.yaml"); + fs::write(&role_overlay_path, "description: overlay").unwrap(); + + // 4. Non-yaml file + let txt_path = roles_dir.join("role.txt"); + fs::write(&txt_path, "not yaml").unwrap(); + + // Write a .kajiplan that ONLY lists role-1.yaml + let plan_file = workspace_dir.join(".kajiplan"); + let planned_paths = vec![role1_path.clone()]; + fs::write(&plan_file, serde_json::to_string(&planned_paths).unwrap()).unwrap(); + + let ui = Arc::new(MockUi { + inputs: std::sync::Mutex::new(Vec::new()), + confirms: std::sync::Mutex::new(Vec::new()), + selects: std::sync::Mutex::new(Vec::new()), + passwords: std::sync::Mutex::new(Vec::new()), + }); + let resolver: Arc = Arc::new(EnvResolver::new(HashMap::new())); + + // Run planning + let res = plan::run( + &client, + workspace_dir.clone(), + false, // changes_only + false, // interactive + &["test-realm".to_string()], + ui.clone(), + resolver, + Some("prod".to_string()), // profile = prod (so role-1.prod.yaml is overlay) + ) + .await; + + assert!(res.is_ok()); + + // The .kajiplan should still exist and contain only role-1 + assert!(plan_file.exists()); + let content = fs::read_to_string(&plan_file).unwrap(); + let planned: Vec = serde_json::from_str(&content).unwrap(); + assert_eq!(planned.len(), 1); + assert_eq!(planned[0], role1_path); +} diff --git a/tests/profile_test.rs b/tests/profile_test.rs index cee5db8..5c00ba7 100644 --- a/tests/profile_test.rs +++ b/tests/profile_test.rs @@ -53,17 +53,18 @@ client_secret: "secret" let cli = Cli { command: Commands::Drift { - workspace: workspace.to_path_buf(), + workspace: Some(workspace.to_path_buf()), }, server: None, // Required unless profile is present realms: vec![], user: None, password: None, - client_id: "ignored".to_string(), + client_id: Some("ignored".to_string()), client_secret: None, profile: Some("test".to_string()), vault_addr: None, vault_token: None, + config: None, }; let profile = load_profile(workspace, "test").await?; @@ -97,17 +98,18 @@ async fn test_init_secrets_with_profile() -> Result<()> { let cli = Cli { command: Commands::Validate { - workspace: workspace.to_path_buf(), + workspace: Some(workspace.to_path_buf()), }, server: None, realms: vec![], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: None, profile: Some("prod".to_string()), vault_addr: None, vault_token: None, + config: None, }; let resolver = init_secrets(&cli, workspace, Some(&profile)).await?; diff --git a/tests/run_app_test.rs b/tests/run_app_test.rs index 995fe2c..6722f20 100644 --- a/tests/run_app_test.rs +++ b/tests/run_app_test.rs @@ -10,16 +10,19 @@ async fn test_run_app_validate() -> Result<()> { let workspace = dir.path().to_path_buf(); let cli = Cli { - command: Commands::Validate { workspace }, + command: Commands::Validate { + workspace: Some(workspace), + }, server: Some("http://localhost:8080".to_string()), realms: vec![], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: None, profile: None, vault_addr: None, vault_token: None, + config: None, }; run_app(cli).await?; @@ -36,18 +39,19 @@ async fn test_run_app_inspect() -> Result<()> { let cli = Cli { command: Commands::Inspect { - workspace, + workspace: Some(workspace), yes: true, }, server: Some(mock_url), realms: vec!["test-realm".to_string()], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: Some("secret".to_string()), profile: None, vault_addr: None, vault_token: None, + config: None, }; run_app(cli).await?; @@ -67,7 +71,7 @@ async fn test_run_app_apply() -> Result<()> { let cli = Cli { command: Commands::Apply { - workspace, + workspace: Some(workspace), yes: true, review: false, }, @@ -75,11 +79,12 @@ async fn test_run_app_apply() -> Result<()> { realms: vec!["test-realm".to_string()], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: Some("secret".to_string()), profile: None, vault_addr: None, vault_token: None, + config: None, }; run_app(cli).await?; @@ -96,7 +101,7 @@ async fn test_run_app_plan() -> Result<()> { let cli = Cli { command: Commands::Plan { - workspace, + workspace: Some(workspace), changes_only: false, interactive: false, }, @@ -104,11 +109,12 @@ async fn test_run_app_plan() -> Result<()> { realms: vec![], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: Some("secret".to_string()), profile: None, vault_addr: None, vault_token: None, + config: None, }; run_app(cli).await?; @@ -122,18 +128,19 @@ async fn test_run_app_clean() -> Result<()> { let cli = Cli { command: Commands::Clean { - workspace, + workspace: Some(workspace), yes: true, }, server: Some("http://localhost:8080".to_string()), realms: vec![], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: None, profile: None, vault_addr: None, vault_token: None, + config: None, }; run_app(cli).await?; @@ -150,16 +157,60 @@ async fn test_run_app_drift() -> Result<()> { let workspace = dir.path().to_path_buf(); let cli = Cli { - command: Commands::Drift { workspace }, + command: Commands::Drift { + workspace: Some(workspace), + }, server: Some(mock_url), realms: vec![], user: None, password: None, - client_id: "admin-cli".to_string(), + client_id: Some("admin-cli".to_string()), client_secret: Some("secret".to_string()), profile: None, vault_addr: None, vault_token: None, + config: None, + }; + + run_app(cli).await?; + Ok(()) +} + +#[tokio::test] +async fn test_run_app_with_config_toml() -> Result<()> { + let dir = tempdir().unwrap(); + let workspace = dir.path().to_path_buf(); + + // Write a custom config toml file + let config_path = dir.path().join("my_config.toml"); + let toml_content = format!( + r#" +workspace = {:?} +server = "http://localhost:8080" +realms = ["master"] +client_id = "toml-client-id" +"#, + workspace.to_string_lossy() + ); + std::fs::write(&config_path, toml_content)?; + + // We pass None for workspace, server, and client_id, + // and let them resolve from the configuration file. + let cli = Cli { + command: Commands::Clean { + workspace: None, + yes: true, + }, + server: None, + realms: vec![], + user: None, + password: None, + client_id: None, + client_secret: None, + profile: None, + vault_addr: None, + vault_token: None, + config: Some(config_path), }; run_app(cli).await?;