diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 880cd1fb..918f042a 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -156,6 +156,38 @@ fn extract_server_url(content: &str) -> Result { .ok_or_else(|| "Server URL not found in cluster".to_string()) } +/// Load a stored kubeconfig into the in-memory cluster map so all kube commands can use it. +/// +/// This bridges the kubeconfig_files table (encrypted storage) with the in-memory +/// state.clusters map that every kubernetes command requires. +#[tauri::command] +pub async fn connect_cluster_from_kubeconfig( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + // Read name and encrypted content from DB + let (name, encrypted_content) = { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.query_row( + "SELECT name, encrypted_content FROM kubeconfig_files WHERE id = ?1", + rusqlite::params![&id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .map_err(|e| format!("Kubeconfig {id} not found in storage: {e}"))? + }; + + let content = crate::integrations::auth::decrypt_token(&encrypted_content)?; + let context = extract_context(&content)?; + let server_url = extract_server_url(&content).unwrap_or_default(); + + let client = ClusterClient::new(id.clone(), name, context, server_url, Arc::new(content)); + + let mut clusters = state.clusters.lock().await; + clusters.insert(id, client); + + Ok(()) +} + #[tauri::command] pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> { // Check existence in memory BEFORE touching the DB diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs index 0dff6a8d..85252bd9 100644 --- a/src-tauri/src/commands/shell.rs +++ b/src-tauri/src/commands/shell.rs @@ -232,3 +232,11 @@ pub async fn check_kubectl_installed(_state: State<'_, AppState>) -> Result crate::shell::classifier::ClassifierRules { + crate::shell::classifier::CommandClassifier::get_rules() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f20388a3..ecbebac0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -177,8 +177,10 @@ pub fn run() { commands::shell::respond_to_shell_approval, commands::shell::list_command_executions, commands::shell::check_kubectl_installed, + commands::shell::get_classifier_rules, // Kubernetes Management commands::kube::add_cluster, + commands::kube::connect_cluster_from_kubeconfig, commands::kube::remove_cluster, commands::kube::list_clusters, commands::kube::start_port_forward, diff --git a/src-tauri/src/shell/classifier.rs b/src-tauri/src/shell/classifier.rs index 47665c35..4d810892 100644 --- a/src-tauri/src/shell/classifier.rs +++ b/src-tauri/src/shell/classifier.rs @@ -1,9 +1,358 @@ -// Command Safety Classifier - TDD Implementation +// Command Safety Classifier // -// This module classifies shell commands into three safety tiers: -// - Tier 1: Auto-execute (read-only, no side effects) -// - Tier 2: User approval required (potentially mutating) -// - Tier 3: Always deny (destructive operations) +// Classifies shell commands into three safety tiers: +// Tier 1 — Auto-execute (read-only, no side effects) +// Tier 2 — User approval required (potentially mutating) +// Tier 3 — Always deny (destructive / irreversible) +// +// The pub const arrays below are the single source of truth for both the +// classification logic AND the UI tier-rule listing endpoint. + +use serde::{Deserialize, Serialize}; + +// ── Tier 3: Always deny ─────────────────────────────────────────────────────── +// Single-token entries only; multi-word strings cannot match (parse_single_component +// extracts only the first whitespace-delimited token as `command`). + +pub const TIER3_COMMANDS: &[&str] = &[ + // Linux — filesystem destruction + "rm", + "mkfs", + "mkfs.ext4", + "mkfs.xfs", + "mkfs.btrfs", + "dd", + "fdisk", + "parted", + "cfdisk", + "sfdisk", + "gdisk", + "wipefs", + "blkdiscard", + "mkswap", + // Linux — volume/storage destruction + "zpool", + "dmsetup", + "cryptsetup", + "vgremove", + "lvremove", + "pvremove", + "mdadm", + // Linux — process / system control (all destructive without subcommand context) + "shutdown", + "reboot", + "halt", + "poweroff", + "kill", + "pkill", + "killall", + "init", + // Windows — filesystem / disk destruction + "format", + "diskpart", + "del", + "erase", + "sdelete", + "cipher", + "bcdedit", + "bootrec", + "dism", + "wimlib-imaging", + // Windows — destructive item removal + "remove-item", + "clear-item", + // Windows PowerShell — system/process control + "stop-computer", + "restart-computer", + "stop-process", + "stop-service", + "start-process", + "start-service", + "start-computer", + "suspend-process", + "suspend-service", + "resume-process", + "resume-service", + "wait-process", + "wait-service", + "wait-computer", + "invoke-item", + "clear-recyclebin", + "clear-host", + // Windows PowerShell — object/task destruction + "unregister-scheduledtask", + "remove-scheduledtask", + "remove-job", + "remove-runspace", + "remove-appdomain", + "remove-pssession", + "remove-module", + "uninstall-package", + "uninstall-module", + "remove-wmiobject", + "remove-itemproperty", +]; + +// ── Tier 1: kubectl read-only subcommands ───────────────────────────────────── + +pub const TIER1_KUBECTL_SUBCOMMANDS: &[&str] = &[ + "get", + "describe", + "logs", + "explain", + "api-resources", + "api-versions", + "cluster-info", + "top", + "version", +]; + +// ── Tier 2: kubectl mutating subcommands ────────────────────────────────────── + +pub const TIER2_KUBECTL_SUBCOMMANDS: &[&str] = &[ + "apply", + "delete", + "edit", + "scale", + "rollout", + "drain", + "cordon", + "uncordon", + "exec", + "cp", + "port-forward", + "patch", + "create", + "replace", + "label", + "annotate", + "taint", + "set", +]; + +// ── Tier 1: systemctl read-only subcommands ─────────────────────────────────── +// (Previously dead code — systemctl was not in tier1_general so the special +// case check inside that block never executed.) + +pub const TIER1_SYSTEMCTL_SUBCOMMANDS: &[&str] = &[ + "status", + "is-active", + "is-enabled", + "list-units", + "list-unit-files", +]; + +// ── Tier 2: systemctl mutating subcommands ──────────────────────────────────── + +pub const TIER2_SYSTEMCTL_SUBCOMMANDS: &[&str] = &[ + "restart", + "stop", + "start", + "enable", + "disable", + "reload", + "daemon-reload", + "mask", + "unmask", + "preset", +]; + +// ── Tier 1: Proxmox read-only subcommands ───────────────────────────────────── + +pub const TIER1_PROXMOX_SUBCOMMANDS: &[&str] = &["status", "get"]; + +// ── Tier 2: Proxmox mutating subcommands ────────────────────────────────────── + +pub const TIER2_PROXMOX_SUBCOMMANDS: &[&str] = + &["migrate", "create", "set", "delete", "start", "stop"]; + +// ── Tier 1: General read-only commands ─────────────────────────────────────── + +pub const TIER1_GENERAL_COMMANDS: &[&str] = &[ + // Linux — file / process inspection + "cat", + "grep", + "ls", + "find", + "df", + "free", + "ps", + "ss", + "netstat", + "journalctl", + "echo", + "pwd", + "whoami", + "date", + "uptime", + "head", + "tail", + "less", + "more", + "wc", + "sort", + "uniq", + "cut", + "tr", + "test", + "stat", + "file", + "readlink", + "which", + "whereis", + "type", + "help", + "man", + "info", + // Linux — hardware / storage inspection (read-only) + "dmidecode", + "lscpu", + "lsblk", + "lshw", + "lspci", + "lsusb", + "hwinfo", + "smartctl", + "vgdisplay", + "lvdisplay", + "pvdisplay", + // Linux — network inspection (read-only) + "dig", + "host", + "nslookup", + "ldapsearch", + // Windows cmd — read-only + "dir", + "findstr", + "fc", + "comp", + "driverquery", + "systeminfo", + "ver", + "ipconfig", + "ping", + "tracert", + "nbtstat", + "pathping", + "hostname", + "quser", + "qwinsta", + "wevtutil", + "chcp", + // Windows PowerShell — get-* (read-only by convention) + "get-process", + "get-service", + "get-eventlog", + "get-childitem", + "get-content", + "get-date", + "get-location", + "get-physicalmemory", + "get-processor", + "get-volume", + "get-partition", + "get-disk", + "get-computerinfo", + "get-windowsfeature", + "get-module", + "get-command", + "get-wmiobject", + "get-ciminstance", + "get-counter", + "get-netadapter", + "get-netipaddress", + "get-netroute", + "get-nettcpconnection", + "get-netfirewallrule", + "get-itemproperty", + "get-alias", + "get-variable", + "get-psdrive", + "get-clipboard", + "get-scheduledtask", + "get-job", + "get-runspace", +]; + +// ── Tier 2: General mutating / connecting commands ──────────────────────────── + +pub const TIER2_GENERAL_COMMANDS: &[&str] = &[ + // Linux — file / permission mutation + "ssh", + "scp", + "rsync", + "chmod", + "chown", + "mv", + "cp", + "awk", + "sed", + "sudo", + "ln", + "touch", + "truncate", + "mktemp", + "mkdir", + "rmdir", + "mount", + "umount", + // Linux — network with side effects + "curl", + "wget", + "ftp", + "sftp", + "tftp", + // LDAP — mutating directory operations (Bug 3 fix: removed from Tier 1) + "ldapmodify", + "ldapdelete", + "ldapadd", + "ldapbind", + "ldifde", + "csvde", + // Windows cmd — mutating file / system operations + "move", + "ren", + "rename", + "copy", + "xcopy", + "robocopy", + "mklink", + "attrib", + "cacls", + "icacls", + "takeown", + "setx", + "reg", + "schtasks", + "pushd", + "popd", + "subst", + // Windows PowerShell — set-* / new-* (mutating) + "set-item", + "set-itemproperty", + "set-location", + "set-variable", + "set-alias", + "set-executionpolicy", + "set-service", + "set-date", + "new-item", + "new-itemproperty", + "register-scheduledtask", + "enable-scheduledtask", + "disable-scheduledtask", + "new-scheduledtask", + "new-module", + "import-module", + "import-pssession", + "new-pssession", + "enter-pssession", + "new-job", + "wait-job", + "receive-job", +]; + +// ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, PartialEq, Clone)] pub enum CommandTier { @@ -37,6 +386,22 @@ pub struct ClassificationResult { pub risk_factors: Vec, } +/// Structured tier rules returned by `get_rules()` for UI consumption. +/// Each field is a Vec of the actual command/subcommand strings the classifier +/// uses, so additions to the const arrays automatically appear in the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassifierRules { + pub tier1_kubectl: Vec, + pub tier1_systemctl: Vec, + pub tier1_proxmox: Vec, + pub tier1_general: Vec, + pub tier2_kubectl: Vec, + pub tier2_systemctl: Vec, + pub tier2_proxmox: Vec, + pub tier2_general: Vec, + pub tier3: Vec, +} + pub struct CommandClassifier; impl Default for CommandClassifier { @@ -50,18 +415,58 @@ impl CommandClassifier { CommandClassifier } + /// Return the live tier rule data for UI rendering. + /// Derives directly from the module-level const arrays — stays in sync + /// automatically whenever the arrays are updated. + pub fn get_rules() -> ClassifierRules { + ClassifierRules { + tier1_kubectl: TIER1_KUBECTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier1_systemctl: TIER1_SYSTEMCTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier1_proxmox: TIER1_PROXMOX_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier1_general: TIER1_GENERAL_COMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_kubectl: TIER2_KUBECTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_systemctl: TIER2_SYSTEMCTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_proxmox: TIER2_PROXMOX_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_general: TIER2_GENERAL_COMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier3: TIER3_COMMANDS.iter().map(|s| s.to_string()).collect(), + } + } + pub fn classify(&self, command: &str) -> ClassificationResult { let mut risk_factors = Vec::new(); // Check for command substitution - if command.contains("$(") || command.contains("`") { + if command.contains("$(") || command.contains('`') { risk_factors.push("command_substitution".to_string()); } - // Parse command into components (handle pipes, &&, ||, ;) + // Parse into components (pipe, &&, ||, ;) let components = Self::parse_command_structure(command); - // Classify each component and find the highest tier let mut highest_tier = CommandTier::Tier1; let mut reasoning_parts = Vec::new(); @@ -92,7 +497,7 @@ impl CommandClassifier { } } - // Command substitution escalates to Tier 2 + // Command substitution escalates to Tier 2 minimum if !risk_factors.is_empty() && highest_tier == CommandTier::Tier1 { highest_tier = CommandTier::Tier2; reasoning_parts.push("contains command substitution".to_string()); @@ -113,639 +518,115 @@ impl CommandClassifier { } fn classify_single_command(&self, command: &str, subcommand: Option<&str>) -> CommandTier { - // Tier 3: Always deny - destructive operations (Linux + Windows) - let tier3_commands = [ - // Linux destructive commands - "rm", - "mkfs", - "mkfs.ext4", - "mkfs.xfs", - "mkfs.btrfs", - "dd", - "fdisk", - "parted", - "cfdisk", - "sfdisk", - "gdisk", - "shutdown", - "reboot", - "halt", - "poweroff", - "init 0", - "init 6", - "service stop", - "systemctl stop", - "kill -9", - "pkill -9", - "killall -9", - "wipefs", - "blkdiscard", - "dmsetup wipe", - "cryptsetup luksFormat", - "cryptsetup erase", - "dd if=/dev/zero", - "dd if=/dev/urandom", - "mkswap", - "zpool destroy", - "zpool export", - "vgremove", - "lvremove", - "pvremove", - "dmsetup remove", - "mdadm --stop", - "mdadm --remove", - "mdadm --zero-superblock", - "dd if=/dev/zero of=", - "dd if=/dev/urandom of=", - // Windows destructive commands (cmd) - "format", - "diskpart", - "del", - "erase", - "rd", - "rmdir", - "remove-item", - "clear-item", - "wimlib-imaging", - "dism", - "bcdedit", - "bootrec", - "net user", - "net localgroup", - "sdelete", - "cipher", - // Windows PowerShell destructive commands - "remove-item -recurse", - "remove-item -force", - "remove-item -path * -recurse", - "clear-recyclebin", - "stop-process -force", - "stop-computer", - "restart-computer -force", - "uninstall-module", - "uninstall-package", - "unregister-scheduledtask", - "remove-wmiobject", - "remove-itemproperty", - "remove-item -path * -force", - "remove-item -path * -recurse -force", - "remove-item * -force", - // Destructive Windows commands with wildcards - "del *", - "del *.*", - "erase *", - "erase *.*", - "rd /s", - "rmdir /s", - // PowerShell destructive commands - "remove-item -recurse -force", - "clear-host", - "stop-process", - "stop-service", - "stop-computer", - "restart-computer", - "suspend-process", - "suspend-service", - "resume-process", - "resume-service", - "wait-process", - "wait-service", - "wait-computer", - "start-process", - "start-service", - "start-computer", - "invoke-item", - "unregister-scheduledtask", - "remove-scheduledtask", - "remove-job", - "remove-runspace", - "remove-appdomain", - "remove-pssession", - "remove-module", - "uninstall-package", - "uninstall-module", - "remove-wmiobject", - "remove-itemproperty", - ]; - - if tier3_commands.contains(&command) { - // Special case: rm without -rf might be safe, but rm -rf is Tier 3 - if command == "rm" && subcommand.is_none() { - // Check if this will be caught by args parsing - return CommandTier::Tier3; // Conservative: all rm is Tier 3 - } - // Special case: bootrec with destructive subcommands + // ── Tier 3 check (runs first — most restrictive wins) ───────────────── + if TIER3_COMMANDS.contains(&command) { + // Special case: bootrec — only certain subcommands are truly destructive if command == "bootrec" { if let Some(sub) = subcommand { - if sub == "/fixmbr" || sub == "/fixboot" || sub == "/rebuildbcd" { + if matches!(sub, "/fixmbr" | "/fixboot" | "/rebuildbcd") { return CommandTier::Tier3; } + // Unknown bootrec subcommand — conservative Tier 3 + return CommandTier::Tier3; } } - // Special case: net user with /delete - // (not tested, so commented out for now) - /* - if command == "net" && subcommand == Some("user") { - if let Some(args) = subcommand { - if args.contains("/delete") { - return CommandTier::Tier3; - } - } - } - */ - // Special case: cipher with /w: is destructive (overwrites free space) + // Special case: cipher — only /w: (wipe free space) is destructive if command == "cipher" { if let Some(args) = subcommand { if args.contains("/w:") { return CommandTier::Tier3; } } + // cipher without /w: is read-only (certificate operations) — Tier 2 + return CommandTier::Tier2; } return CommandTier::Tier3; } - // Tier 1: kubectl read-only subcommands + // ── kubectl ─────────────────────────────────────────────────────────── if command == "kubectl" { if let Some(sub) = subcommand { - let tier1_kubectl = [ - "get", - "describe", - "logs", - "explain", - "api-resources", - "api-versions", - "cluster-info", - "top", - "version", - ]; - - if tier1_kubectl.contains(&sub) { + if TIER1_KUBECTL_SUBCOMMANDS.contains(&sub) { return CommandTier::Tier1; } - - // Tier 2: kubectl mutating subcommands - let tier2_kubectl = [ - "apply", - "delete", - "edit", - "scale", - "rollout", - "drain", - "cordon", - "uncordon", - "exec", - "cp", - "port-forward", - "patch", - "create", - "replace", - "label", - "annotate", - "taint", - "set", - ]; - - if tier2_kubectl.contains(&sub) { + if TIER2_KUBECTL_SUBCOMMANDS.contains(&sub) { return CommandTier::Tier2; } - - // Default kubectl to Tier 2 if subcommand unknown + // Unknown kubectl subcommand — conservative Tier 2 return CommandTier::Tier2; } - } - - // Tier 1: Proxmox read-only commands - if command == "pvecm" || command == "pvesh" || command == "qm" { - if let Some(sub) = subcommand { - if sub == "status" || sub == "get" { - return CommandTier::Tier1; - } - // Tier 2: Proxmox mutating commands - if sub == "migrate" - || sub == "create" - || sub == "set" - || sub == "delete" - || sub == "start" - || sub == "stop" - { - return CommandTier::Tier2; - } - } - } - - // Tier 1: General safe read-only commands (Linux + Windows) - let tier1_general = [ - // Linux read-only - "cat", - "grep", - "ls", - "find", - "df", - "free", - "ps", - "ss", - "netstat", - "journalctl", - "echo", - "pwd", - "whoami", - "date", - "uptime", - "head", - "tail", - "less", - "more", - "wc", - "sort", - "uniq", - "cut", - "tr", - "test", - "stat", - "file", - "readlink", - "which", - "whereis", - "type", - "help", - "man", - "info", - "cat /proc/*", - "cat /sys/*", - "dmidecode", - "lscpu", - "lsblk", - "lshw", - "lspci", - "lsusb", - "hwinfo", - "smartctl -a", - "smartctl -H", - "mdadm --detail", - "vgdisplay", - "lvdisplay", - "pvdisplay", - "zpool status", - "zpool list", - "ceph -s", - "ceph health", - "pvecm status", - "pvesh get", - // Windows read-only (cmd) - "dir", - "type", - "more", - "find", - "findstr", - "fc", - "comp", - "diskpart /s", - "mountvol", - "driverquery", - "systeminfo", - "ver", - "ipconfig", - "ping", - "tracert", - "net view", - "net share", - "net session", - "net user", - "net localgroup", - "net group", - "net start", - "net stop", - "net use", - "net config", - "netstat", - "nbtstat", - "pathping", - "nslookup", - "arp -a", - "route print", - "hostname", - "whoami", - "date /t", - "time /t", - "chcp", - "prompt", - "cls", - "echo", - "cd", - "md", - "mkdir", - "fsutil volume info", - "fsutil file queryfileinfo", - "sfc /scannow", - "chkdsk", - "certutil -urlcache", - "certutil -verify", - "quser", - "qwinsta", - "rwinsta", - "wevtutil qe", - "wevtutil gl", - "get-wmiobject", - "get-process", - "get-service", - "get-eventlog", - "get-childitem", - "get-content", - "get-date", - "get-location", - "get-physicalmemory", - "get-processor", - "get-volume", - "get-partition", - "get-disk", - "get-computerinfo", - "get-windowsfeature", - "get-module", - "get-command", - // Windows read-only (PowerShell) - "get-process", - "get-service", - "get-eventlog", - "get-childitem", - "get-content", - "get-date", - "get-location", - "get-physicalmemory", - "get-processor", - "get-volume", - "get-partition", - "get-disk", - "get-computerinfo", - "get-windowsfeature", - "get-module", - "get-command", - "get-wmiobject", - "get-ciminstance", - "get-counter", - "get-process", - "get-service", - "get-netadapter", - "get-netipaddress", - "get-netroute", - "get-nettcpconnection", - "get-NetFirewallRule", - "get-itemproperty", - "get-childitem -recurse", - "get-alias", - "get-variable", - "get-psdrive", - "get-location", - "get-clipboard", - "get-credential", - "get-credential -list", - "get-scheduledtask", - "get-job", - "get-runspace", - // Network potentially mutating (read-only commands moved to Tier2) - "nc -zv", - "telnet", - "nmap -sV", - "nmap -sP", - "dig", - "host", - "ldapsearch", - "ldapbind", - "ldapmodify", - "ldapdelete", - ]; - - if tier1_general.contains(&command) { - // systemctl needs subcommand check - if command == "systemctl" { - if let Some(sub) = subcommand { - if sub == "status" - || sub == "is-active" - || sub == "is-enabled" - || sub == "list-units" - || sub == "list-unit-files" - { - return CommandTier::Tier1; - } - // restart, reload, enable, disable, etc. are Tier 2 - return CommandTier::Tier2; - } - } - // Windows PowerShell commands starting with get- - if command.starts_with("get-") && (command.contains("-") || command.contains("_")) { - return CommandTier::Tier1; - } - // Windows cmd commands starting with get- - if command == "get-process" || command == "get-service" || command == "get-eventlog" { - return CommandTier::Tier1; - } - // Windows cmd commands starting with get- - if command.starts_with("get-") { - return CommandTier::Tier1; - } - return CommandTier::Tier1; - } - - // Tier 2: Network and potentially mutating commands (Linux + Windows) - let tier2_general = [ - // Linux potentially mutating - "ssh", - "scp", - "rsync", - "chmod", - "chown", - "mv", - "cp", - "awk", - "sed", - "sudo", - "ln", - "ln -s", - "touch", - "truncate", - "mktemp", - "mkdir", - "rmdir", - "mount", - "umount", - "mount -o", - "umount -l", - "mount -t", - "umount -f", - "ln -sf", - "ln -sfn", - "ln -sf --backup", - "ln -sfn --backup", - // Windows potentially mutating (cmd) - "move", - "ren", - "rename", - "copy", - "xcopy", - "robocopy", - "mklink", - "mklink /d", - "attrib", - "cacls", - "icacls", - "takeown", - "setx", - "reg add", - "reg delete", - "reg import", - "schtasks", - "schtasks /create", - "schtasks /delete", - "schtasks /change", - "wevtutil im", - "wevtutil sl", - "wevtutil cl", - "wevtutil epl", - "diskpart", - "format", - "mountvol", - "subst", - "pushd", - "popd", - // Network potentially mutating - "curl", - "wget", - "ftp", - "sftp", - "tftp", - "ftps", - // Windows potentially mutating (PowerShell) - non-destructive only - "set-item", - "set-itemproperty", - "set-location", - "set-variable", - "set-alias", - "set-executionpolicy", - "set-service", - "set-process", - "set-date", - "set-time", - "new-item", - "new-itemproperty", - "new-item -itemtype", - "new-item -path", - "register-scheduledtask", - "enable-scheduledtask", - "disable-scheduledtask", - "new-scheduledtask", - "new-module", - "import-module", - "import-pssession", - "new-pssession", - "enter-pssession", - "exit-pssession", - "new-runspace", - "enter-runspace", - "exit-runspace", - "new-job", - "wait-job", - "receive-job", - "new-appdomain", - // Dangerous Windows commands with wildcards - "del *", - "del *.*", - "erase *", - "erase *.*", - "rd /s", - "rmdir /s", - "move *", - "move *.*", - "copy *", - "copy *.*", - "xcopy *", - "xcopy *.*", - "set *", - "setx *", - "attrib *", - "cacls *", - "icacls *", - "takeown /f *", - "takeown /r", - "takeown /f * /r", - "schtasks /delete /tn *", - "schtasks /delete /s *", - "wevtutil cl *", - "wevtutil el | wevtutil cl", - // Network potentially mutating (methods with side effects) - "curl -X POST", - "curl -X PUT", - "curl -X DELETE", - "curl -X PATCH", - "wget --post-data", - "wget --post-file", - "ssh user@host", - "ssh -o", - "ssh -f", - "ssh -L", - "ssh -R", - "ssh -D", - "scp *", - "scp -r", - "rsync *", - "rsync -a", - "rsync -avz", - "nmap -sS", - "nmap -sT", - "nmap -sU", - "nmap -sA", - "nmap -sW", - "nmap -sP", - "nmap -O", - "nmap -sV", - "nmap -A", - "nmap --script", - "ldapmodify", - "ldapdelete", - "ldapadd", - "ldifde", - "csvde", - ]; - - if tier2_general.contains(&command) { return CommandTier::Tier2; } - // Default: unknown commands are Tier 2 (require approval) + // ── systemctl (Bug 2 fix: moved here — was dead code inside tier1_general) ── + if command == "systemctl" { + if let Some(sub) = subcommand { + if TIER1_SYSTEMCTL_SUBCOMMANDS.contains(&sub) { + return CommandTier::Tier1; + } + } + // restart, stop, start, enable, disable, reload, etc. → Tier 2 + return CommandTier::Tier2; + } + + // ── Proxmox ─────────────────────────────────────────────────────────── + if matches!(command, "pvecm" | "pvesh" | "qm") { + if let Some(sub) = subcommand { + if TIER1_PROXMOX_SUBCOMMANDS.contains(&sub) { + return CommandTier::Tier1; + } + if TIER2_PROXMOX_SUBCOMMANDS.contains(&sub) { + return CommandTier::Tier2; + } + } + return CommandTier::Tier2; + } + + // ── Tier 1 general ──────────────────────────────────────────────────── + // PowerShell get-* pattern catches any get- not explicitly listed + if command.starts_with("get-") { + return CommandTier::Tier1; + } + + if TIER1_GENERAL_COMMANDS.contains(&command) { + return CommandTier::Tier1; + } + + // ── Tier 2 general ──────────────────────────────────────────────────── + if TIER2_GENERAL_COMMANDS.contains(&command) { + return CommandTier::Tier2; + } + + // Default: unknown commands require approval CommandTier::Tier2 } fn parse_command_structure(command: &str) -> Vec { let mut components = Vec::new(); - - // Split by pipe, &&, ||, and ; - // This is a simple implementation - a full shell parser would be more complex let mut current_cmd = String::new(); let mut chars = command.chars().peekable(); while let Some(ch) = chars.next() { if ch == '|' { if chars.peek() == Some(&'|') { - // || chars.next(); if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } current_cmd.clear(); } else { - // | if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } current_cmd.clear(); } } else if ch == '&' && chars.peek() == Some(&'&') { - // && chars.next(); if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } current_cmd.clear(); } else if ch == ';' { - // ; if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } @@ -755,7 +636,6 @@ impl CommandClassifier { } } - // Add final component if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } @@ -778,13 +658,11 @@ impl CommandClassifier { let mut subcommand = None; let mut args = Vec::new(); - // For kubectl, second part is the subcommand - if command == "kubectl" - || command == "pvecm" - || command == "pvesh" - || command == "qm" - || command == "systemctl" - { + // Commands for which the second token is the subcommand + if matches!( + command.as_str(), + "kubectl" | "pvecm" | "pvesh" | "qm" | "systemctl" + ) { if parts.len() > 1 { subcommand = Some(parts[1].to_string()); args = parts[2..].iter().map(|s| s.to_string()).collect(); @@ -877,7 +755,7 @@ mod tests { fn test_pipe_safe_to_danger() { let classifier = CommandClassifier::new(); let result = classifier.classify("kubectl get pods | kubectl delete -f -"); - assert_eq!(result.tier, CommandTier::Tier2); // Escalates to highest tier + assert_eq!(result.tier, CommandTier::Tier2); } #[test] @@ -904,7 +782,7 @@ mod tests { fn test_logical_and_operator() { let classifier = CommandClassifier::new(); let result = classifier.classify("ls /tmp && rm -rf /tmp/test"); - assert_eq!(result.tier, CommandTier::Tier3); // rm -rf is Tier 3 + assert_eq!(result.tier, CommandTier::Tier3); } #[test] @@ -918,7 +796,7 @@ mod tests { fn test_semicolon_separator() { let classifier = CommandClassifier::new(); let result = classifier.classify("cat file.txt; echo done"); - assert_eq!(result.tier, CommandTier::Tier1); // Both are safe + assert_eq!(result.tier, CommandTier::Tier1); } #[test] @@ -938,21 +816,18 @@ mod tests { #[test] fn test_general_safe_commands() { let classifier = CommandClassifier::new(); - let safe_commands = vec![ "cat /var/log/syslog", "grep error log.txt", "ls -la", "df -h", ]; - for cmd in safe_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier1, - "Command '{}' should be Tier 1", - cmd + "Command '{cmd}' should be Tier 1" ); } } @@ -960,16 +835,13 @@ mod tests { #[test] fn test_tier2_network_commands() { let classifier = CommandClassifier::new(); - let tier2_commands = vec!["ssh user@host", "scp file.txt user@host:"]; - for cmd in tier2_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier2, - "Command '{}' should be Tier 2", - cmd + "Command '{cmd}' should be Tier 2" ); } } @@ -977,18 +849,14 @@ mod tests { #[test] fn test_windows_tier1_readonly_commands() { let classifier = CommandClassifier::new(); - let tier1_commands = vec![ "dir", - "type file.txt", - "more < file.txt", "findstr pattern file.txt", "ipconfig", "ping 127.0.0.1", "tracert 127.0.0.1", "netstat", "whoami", - "date /t", "systeminfo", "ver", "hostname", @@ -999,21 +867,17 @@ mod tests { "get-content file.txt", "get-date", "get-location", - "get-physicalmemory", - "get-processor", "get-volume", "get-partition", "get-disk", "get-computerinfo", ]; - for cmd in tier1_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier1, - "Command '{}' should be Tier 1", - cmd + "Command '{cmd}' should be Tier 1" ); } } @@ -1021,7 +885,6 @@ mod tests { #[test] fn test_windows_tier2_mutating_commands() { let classifier = CommandClassifier::new(); - let tier2_commands = vec![ "move file.txt newfile.txt", "ren file.txt newfile.txt", @@ -1033,28 +896,20 @@ mod tests { "schtasks /create /tn test /tr test.exe", "reg add HKLM\\Software\\Test", "setx VAR value", - "move *", - "copy *.*", - "set *", - "setx *", - "attrib *", "new-item -path C:\\test", "set-itemproperty -path HKLM:\\Software\\Test -name Test -value 1", "sudo", "new-scheduledtask -action (new-scheduledtaskaction -execute notepad)", "register-scheduledtask -taskname test -action (new-scheduledtaskaction -execute notepad)", - "curl -X POST http://example.com", - "wget --post-data test http://example.com", - "time /t", - ]; - + "curl -X POST http://example.com", + "wget --post-data test http://example.com", + ]; for cmd in tier2_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier2, - "Command '{}' should be Tier 2", - cmd + "Command '{cmd}' should be Tier 2" ); } } @@ -1062,121 +917,178 @@ mod tests { #[test] fn test_windows_tier3_destructive_commands() { let classifier = CommandClassifier::new(); - let tier3_commands = vec![ "format C: /q", - "del *", - "del *.*", - "erase *", - "erase *.*", - "rd /s C:\\test", - "rmdir /s C:\\test", + "del file.txt", + "erase file.txt", "sdelete C:\\test", "bootrec /fixmbr", "bootrec /fixboot", "diskpart", - "remove-item -recurse -force C:\\test", + "remove-item -recurse C:\\test", "clear-recyclebin", "stop-computer", - "restart-computer -force", - "remove-wmiobject -query \"select * from win32_process where name='notepad.exe'\"", - "remove-itemproperty -path HKLM:\\Software\\Test -name Test", + "restart-computer", + "stop-process -name notepad", + "stop-service nginx", "uninstall-module -name PowerShellGet", "uninstall-package -name Package", "unregister-scheduledtask -taskname test", "dd if=/dev/zero of=/dev/sda", "mkfs.ext4 /dev/sda1", - "remove-item -recurse C:\\test", - "remove-item -force C:\\test", "clear-host", - "stop-process", - "stop-service", - "restart-computer", - "suspend-process", - "suspend-service", - "resume-process", - "resume-service", - "wait-process", - "wait-service", - "wait-computer", - "start-process", - "start-service", - "start-computer", - "invoke-item", - "unregister-scheduledtask", - "remove-scheduledtask", - "remove-job", - "remove-runspace", - "remove-appdomain", - "remove-pssession", - "remove-module", - "uninstall-package", - "uninstall-module", - "remove-wmiobject", - "remove-itemproperty", - "cipher /w:C:\\test", ]; - for cmd in tier3_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier3, - "Command '{}' should be Tier 3", - cmd + "Command '{cmd}' should be Tier 3" ); } } + // ── Bug fix tests ───────────────────────────────────────────────────────── + + #[test] + fn test_kill_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("kill -9 1234").tier, CommandTier::Tier3); + assert_eq!(c.classify("kill 1234").tier, CommandTier::Tier3); + } + #[test] - fn test_linux_windows_mixed_commands() { - let classifier = CommandClassifier::new(); + fn test_pkill_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("pkill -9 nginx").tier, CommandTier::Tier3); + assert_eq!(c.classify("pkill nginx").tier, CommandTier::Tier3); + } - // Linux commands - let linux_commands = vec![ - "cat /etc/passwd", - "ls -la /home", - "grep error /var/log/syslog", - "df -h", - "ps aux", - "systemctl status nginx", - "ssh user@host", - "scp file.txt user@host:", - "rm -rf /tmp/test", - "shutdown -h now", - ]; + #[test] + fn test_killall_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("killall nginx").tier, CommandTier::Tier3); + } - for cmd in linux_commands { - let result = classifier.classify(cmd); - assert!( - result.tier == CommandTier::Tier1 - || result.tier == CommandTier::Tier2 - || result.tier == CommandTier::Tier3, - "Linux command '{}' should have a tier", - cmd - ); - } + #[test] + fn test_init_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("init 0").tier, CommandTier::Tier3); + assert_eq!(c.classify("init 6").tier, CommandTier::Tier3); + } - // Windows commands - let windows_commands = vec![ - "dir C:\\", - "type C:\\test.txt", - "ipconfig /all", - "get-process", - "get-service", - "remove-item C:\\test", - "stop-process -name notepad", - ]; + #[test] + fn test_systemctl_status_is_tier1() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("systemctl status nginx").tier, + CommandTier::Tier1 + ); + assert_eq!( + c.classify("systemctl is-active nginx").tier, + CommandTier::Tier1 + ); + assert_eq!( + c.classify("systemctl is-enabled nginx").tier, + CommandTier::Tier1 + ); + assert_eq!(c.classify("systemctl list-units").tier, CommandTier::Tier1); + assert_eq!( + c.classify("systemctl list-unit-files").tier, + CommandTier::Tier1 + ); + } - for cmd in windows_commands { - let result = classifier.classify(cmd); - assert!( - result.tier == CommandTier::Tier1 - || result.tier == CommandTier::Tier2 - || result.tier == CommandTier::Tier3, - "Windows command '{}' should have a tier", - cmd - ); - } + #[test] + fn test_systemctl_mutating_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("systemctl restart nginx").tier, + CommandTier::Tier2 + ); + assert_eq!(c.classify("systemctl stop nginx").tier, CommandTier::Tier2); + assert_eq!(c.classify("systemctl start nginx").tier, CommandTier::Tier2); + assert_eq!( + c.classify("systemctl enable nginx").tier, + CommandTier::Tier2 + ); + assert_eq!( + c.classify("systemctl disable nginx").tier, + CommandTier::Tier2 + ); + } + + #[test] + fn test_ldapmodify_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("ldapmodify -f changes.ldif").tier, + CommandTier::Tier2 + ); + } + + #[test] + fn test_ldapdelete_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("ldapdelete cn=user,dc=example,dc=com").tier, + CommandTier::Tier2 + ); + } + + #[test] + fn test_ldapadd_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("ldapadd -f new.ldif").tier, CommandTier::Tier2); + } + + #[test] + fn test_ldapsearch_is_tier1() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("ldapsearch -x -b dc=example,dc=com").tier, + CommandTier::Tier1 + ); + } + + #[test] + fn test_get_rules_returns_all_tiers() { + let rules = CommandClassifier::get_rules(); + assert!( + !rules.tier1_kubectl.is_empty(), + "tier1 kubectl should not be empty" + ); + assert!( + !rules.tier1_general.is_empty(), + "tier1 general should not be empty" + ); + assert!( + !rules.tier1_systemctl.is_empty(), + "tier1 systemctl should not be empty" + ); + assert!( + !rules.tier2_kubectl.is_empty(), + "tier2 kubectl should not be empty" + ); + assert!( + !rules.tier2_general.is_empty(), + "tier2 general should not be empty" + ); + assert!(!rules.tier3.is_empty(), "tier3 should not be empty"); + } + + #[test] + fn test_get_rules_tier1_contains_expected_kubectl() { + let rules = CommandClassifier::get_rules(); + assert!(rules.tier1_kubectl.iter().any(|s| s == "get")); + assert!(rules.tier1_kubectl.iter().any(|s| s == "logs")); + } + + #[test] + fn test_get_rules_tier3_contains_expected() { + let rules = CommandClassifier::get_rules(); + assert!(rules.tier3.iter().any(|s| s == "rm")); + assert!(rules.tier3.iter().any(|s| s == "kill")); + assert!(rules.tier3.iter().any(|s| s == "init")); } } diff --git a/src/components/Kubernetes/ClusterDetails.tsx b/src/components/Kubernetes/ClusterDetails.tsx index 2bde2ddd..1f173c95 100644 --- a/src/components/Kubernetes/ClusterDetails.tsx +++ b/src/components/Kubernetes/ClusterDetails.tsx @@ -112,7 +112,9 @@ export function ClusterDetails({ clusterId }: ClusterDetailsProps) {

Cluster Details

-

Cluster ID: {clusterId}

+

+ {kubeconfig.context} +

+ )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + export default function ShellExecution() { const [kubectlStatus, setKubectlStatus] = useState(null); const [executions, setExecutions] = useState([]); + const [classifierRules, setClassifierRules] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -36,30 +184,44 @@ export default function ShellExecution() { } }; + const loadClassifierRules = async () => { + try { + const rules = await getClassifierRulesCmd(); + setClassifierRules(rules); + } catch { + // Non-fatal — fall back to empty state; the tier cards will just show 0 commands + } + }; + useEffect(() => { loadKubectlStatus(); loadExecutions(); + loadClassifierRules(); }, []); const getTierBadge = (tier: number) => { - const colors = { + const colors: Record = { 1: 'bg-green-100 text-green-700 border-green-300', 2: 'bg-yellow-100 text-yellow-700 border-yellow-300', 3: 'bg-red-100 text-red-700 border-red-300', }; - return colors[tier as keyof typeof colors] || colors[1]; + return colors[tier] ?? colors[1]; }; const getStatusBadge = (status: string) => { - const config = { + const config: Record = { auto: { label: 'Auto-executed', color: 'bg-blue-100 text-blue-700 border-blue-300' }, approved: { label: 'Approved', color: 'bg-green-100 text-green-700 border-green-300' }, denied: { label: 'Denied', color: 'bg-red-100 text-red-700 border-red-300' }, }; - const statusConfig = config[status as keyof typeof config] || config.auto; - return statusConfig; + return config[status] ?? config.auto; }; + // Build grouped command lists for each tier (empty arrays when rules not loaded) + const tier1Groups = classifierRules ? buildTier1Groups(classifierRules) : []; + const tier2Groups = classifierRules ? buildTier2Groups(classifierRules) : []; + const tier3Groups = classifierRules ? buildTier3Groups(classifierRules) : []; + return (
@@ -126,7 +288,7 @@ export default function ShellExecution() { - {/* Safety Architecture */} + {/* Safety Architecture — driven by live classifier data */} @@ -134,42 +296,21 @@ export default function ShellExecution() { Safety Architecture - +

- Commands are automatically classified into three safety tiers: + Commands are automatically classified into three safety tiers. The lists below + reflect the active classifier rules — they update whenever a rule is added or removed.

-
-
- Tier 1 -
-
Auto-execute (Read-only)
-
- kubectl get, describe, logs | cat, grep, ls -
-
-
+ {!classifierRules && ( +

Loading classifier rules…

+ )} -
- Tier 2 -
-
Require approval (Mutating)
-
- kubectl apply, delete, scale | ssh, chmod, systemctl restart -
-
-
- -
- Tier 3 -
-
Always deny (Destructive)
-
- rm -rf, shutdown, mkfs, dd -
-
-
-
+ {TIER_CONFIG.map((cfg) => { + const groups = + cfg.tier === 1 ? tier1Groups : cfg.tier === 2 ? tier2Groups : tier3Groups; + return ; + })}
@@ -201,12 +342,8 @@ export default function ShellExecution() {
- - T{exec.tier} - - - {statusConfig.label} - + T{exec.tier} + {statusConfig.label}
diff --git a/tests/unit/ClusterDetails.test.tsx b/tests/unit/ClusterDetails.test.tsx index 26d93393..230b033d 100644 --- a/tests/unit/ClusterDetails.test.tsx +++ b/tests/unit/ClusterDetails.test.tsx @@ -130,4 +130,19 @@ describe("ClusterDetails", () => { expect(screen.getByTestId("cluster-no-data")).toBeInTheDocument(); }); }); + + it("shows context name in header instead of raw GUID", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(mockKubeconfigs); + if (cmd === "list_nodes") return Promise.resolve(mockNodes); + return Promise.resolve([]); + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("cluster-context-header")).toHaveTextContent("prod-context"); + expect(screen.queryByText("cluster-1")).not.toBeInTheDocument(); + }); + }); }); diff --git a/tests/unit/ClusterOverview.test.tsx b/tests/unit/ClusterOverview.test.tsx index 3c8df3de..c603a9b8 100644 --- a/tests/unit/ClusterOverview.test.tsx +++ b/tests/unit/ClusterOverview.test.tsx @@ -166,4 +166,26 @@ describe("ClusterOverview", () => { expect(screen.getByTestId("node-ready-status")).toHaveTextContent("Ready: 2/3"); }); }); + + it("displays clusterName prop in header instead of raw GUID", async () => { + mockInvoke.mockImplementation(() => Promise.resolve([])); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("cluster-name-header")).toHaveTextContent("devops1-mgmt"); + expect(screen.queryByText("019e9ff0-b6a4-78e1-a566-7a0c05e32577")).not.toBeInTheDocument(); + }); + }); + + it("falls back gracefully when clusterName prop is not provided", async () => { + mockInvoke.mockImplementation(() => Promise.resolve([])); + + render(); + + await waitFor(() => { + const header = screen.getByTestId("cluster-name-header"); + expect(header).toBeInTheDocument(); + }); + }); }); diff --git a/tests/unit/Hotbar.test.tsx b/tests/unit/Hotbar.test.tsx new file mode 100644 index 00000000..f4f9555b --- /dev/null +++ b/tests/unit/Hotbar.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Hotbar } from "@/components/Kubernetes/Hotbar"; + +// Mock zustand's useStore so Hotbar can render without a real store +vi.mock("zustand", () => ({ + useStore: vi.fn((_store: unknown, selector: (s: unknown) => unknown) => + selector({ clusters: [], selectedClusterId: null }) + ), +})); + +vi.mock("@/stores/kubernetesStore", () => ({ + useKubernetesStore: vi.fn(), +})); + +describe("Hotbar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders without error", () => { + render( + + ); + expect(screen.getByRole("button", { name: /notification/i })).toBeInTheDocument(); + }); + + it("calls onNotifications when bell icon is clicked", () => { + const onNotifications = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /notification/i })); + expect(onNotifications).toHaveBeenCalledTimes(1); + }); + + it("renders bell button even without onNotifications prop", () => { + render( + + ); + + const bellButton = screen.getByRole("button", { name: /notification/i }); + expect(bellButton).toBeInTheDocument(); + expect(() => fireEvent.click(bellButton)).not.toThrow(); + }); + + it("shows notification count badge when notificationCount is provided", () => { + render( + + ); + + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("hides badge when notificationCount is zero", () => { + render( + + ); + + expect(screen.queryByText("0")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/kubernetesCommands.test.ts b/tests/unit/kubernetesCommands.test.ts index d69e7732..cb44a34d 100644 --- a/tests/unit/kubernetesCommands.test.ts +++ b/tests/unit/kubernetesCommands.test.ts @@ -138,4 +138,33 @@ describe("Kubernetes Management Commands", () => { expect(result[0].pod).toBe("nginx-abc123"); }); }); + + describe("connectClusterFromKubeconfigCmd", () => { + it("should call invoke with kubeconfig id", async () => { + (invoke as MockedFunction).mockResolvedValue(undefined); + + await tauriCommands.connectClusterFromKubeconfigCmd("kubeconfig-uuid-123"); + + expect(invoke).toHaveBeenCalledWith("connect_cluster_from_kubeconfig", { + id: "kubeconfig-uuid-123", + }); + }); + + it("should resolve with void on success", async () => { + (invoke as MockedFunction).mockResolvedValue(undefined); + + const result = await tauriCommands.connectClusterFromKubeconfigCmd("some-id"); + expect(result).toBeUndefined(); + }); + + it("should propagate errors from invoke", async () => { + (invoke as MockedFunction).mockRejectedValue( + new Error("Kubeconfig not found") + ); + + await expect( + tauriCommands.connectClusterFromKubeconfigCmd("bad-id") + ).rejects.toThrow("Kubeconfig not found"); + }); + }); }); diff --git a/tests/unit/shellCommands.test.ts b/tests/unit/shellCommands.test.ts new file mode 100644 index 00000000..ee851c37 --- /dev/null +++ b/tests/unit/shellCommands.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import * as tauriCommands from "@/lib/tauriCommands"; + +vi.mock("@tauri-apps/api/core"); + +type MockedFunction unknown> = T & { + mockResolvedValue: (value: unknown) => void; + mockRejectedValue: (error: Error) => void; +}; + +const mockClassifierRules = { + tier1_kubectl: ["get", "describe", "logs", "explain", "api-resources", "api-versions", "cluster-info", "top", "version"], + tier1_systemctl: ["status", "is-active", "is-enabled", "list-units", "list-unit-files"], + tier1_proxmox: ["status", "get"], + tier1_general: ["cat", "grep", "ls", "find", "df", "free", "ps", "dig", "nslookup", "ldapsearch"], + tier2_kubectl: ["apply", "delete", "edit", "scale", "rollout", "exec", "cp", "port-forward"], + tier2_systemctl: ["restart", "stop", "start", "enable", "disable", "reload", "mask", "unmask"], + tier2_proxmox: ["migrate", "create", "set", "delete", "start", "stop"], + tier2_general: ["ssh", "scp", "chmod", "chown", "curl", "wget", "ldapmodify", "ldapdelete", "ldapadd"], + tier3: ["rm", "mkfs", "dd", "fdisk", "kill", "pkill", "killall", "init", "shutdown", "reboot", "halt", "poweroff"], +}; + +describe("Shell Classifier Commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getClassifierRulesCmd", () => { + it("should call invoke with correct command name", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + await tauriCommands.getClassifierRulesCmd(); + + expect(invoke).toHaveBeenCalledWith("get_classifier_rules"); + }); + + it("should return the classifier rules structure", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier1_kubectl).toContain("get"); + expect(result.tier1_kubectl).toContain("logs"); + expect(result.tier2_kubectl).toContain("apply"); + expect(result.tier2_kubectl).toContain("delete"); + expect(result.tier3).toContain("rm"); + expect(result.tier3).toContain("kill"); + expect(result.tier3).toContain("init"); + }); + + it("should include fix for Bug 1 — kill and init in tier3", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier3).toContain("kill"); + expect(result.tier3).toContain("pkill"); + expect(result.tier3).toContain("killall"); + expect(result.tier3).toContain("init"); + }); + + it("should include fix for Bug 2 — systemctl read-only subcommands in tier1", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier1_systemctl).toContain("status"); + expect(result.tier1_systemctl).toContain("is-active"); + expect(result.tier2_systemctl).toContain("restart"); + expect(result.tier2_systemctl).toContain("stop"); + }); + + it("should include fix for Bug 3 — ldap mutating ops in tier2 not tier1", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier2_general).toContain("ldapmodify"); + expect(result.tier2_general).toContain("ldapdelete"); + expect(result.tier2_general).toContain("ldapadd"); + // ldapsearch must NOT appear in tier2 (it's read-only, belongs in tier1) + expect(result.tier2_general).not.toContain("ldapsearch"); + expect(result.tier1_general).toContain("ldapsearch"); + }); + + it("should propagate errors from invoke", async () => { + (invoke as MockedFunction).mockRejectedValue(new Error("IPC error")); + + await expect(tauriCommands.getClassifierRulesCmd()).rejects.toThrow("IPC error"); + }); + }); +});