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.
+ - ✓ kaji.toml / .kaji.toml project config
- ✓ profiles/dev.yaml, staging.yaml, prod.yaml
- - ✓ YAML deep-merge overlays per resource
- - ✓ Per-profile secrets files
+ - ✓ YAML overlays deep-merged at runtime
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?;