diff --git a/crates/openshell-cli/tests/cli_help_integration.rs b/crates/openshell-cli/tests/cli_help_integration.rs new file mode 100644 index 0000000000..fccb7fd4ae --- /dev/null +++ b/crates/openshell-cli/tests/cli_help_integration.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Rendered help and command-shape checks for the `openshell` binary. + +mod common; + +use common::run_isolated; + +#[test] +fn root_help_shows_top_level_commands() { + let output = run_isolated(&["--help"]); + assert_eq!(output.code, 0, "openshell --help:\n{}", output.combined); + + for command in ["gateway", "status", "sandbox", "forward", "logs", "policy"] { + assert!( + output.combined.contains(command), + "expected '{command}' in openshell --help:\n{}", + output.combined + ); + } +} + +#[test] +fn gateway_help_shows_registration_commands_and_omits_lifecycle_commands() { + let output = run_isolated(&["gateway", "--help"]); + assert_eq!(output.code, 0, "gateway --help:\n{}", output.combined); + + for command in ["add", "remove", "login", "logout", "select", "info", "list"] { + assert!( + output.combined.contains(command), + "expected '{command}' in gateway --help:\n{}", + output.combined + ); + } + for removed in ["start", "stop", "destroy"] { + assert!( + !output.combined.contains(removed), + "unexpected removed command '{removed}' in gateway --help:\n{}", + output.combined + ); + } +} + +#[test] +fn sandbox_help_shows_transfer_and_lifecycle_commands() { + let output = run_isolated(&["sandbox", "--help"]); + assert_eq!(output.code, 0, "sandbox --help:\n{}", output.combined); + + for command in [ + "upload", "download", "create", "get", "list", "delete", "connect", + ] { + assert!( + output.combined.contains(command), + "expected '{command}' in sandbox --help:\n{}", + output.combined + ); + } +} + +#[test] +fn sandbox_create_help_shows_creation_flags() { + let output = run_isolated(&["sandbox", "create", "--help"]); + assert_eq!( + output.code, 0, + "sandbox create --help:\n{}", + output.combined + ); + + for flag in [ + "--gpu", + "--upload", + "--no-git-ignore", + "--editor", + "--auto-providers", + "--no-auto-providers", + ] { + assert!( + output.combined.contains(flag), + "expected '{flag}' in sandbox create --help:\n{}", + output.combined + ); + } +} + +#[test] +fn sandbox_connect_help_shows_editor_flag() { + let output = run_isolated(&["sandbox", "connect", "--help"]); + assert_eq!( + output.code, 0, + "sandbox connect --help:\n{}", + output.combined + ); + assert!(output.combined.contains("--editor"), "{}", output.combined); +} + +#[test] +fn gateway_add_help_shows_endpoint_and_gateway_type_flags() { + let output = run_isolated(&["gateway", "add", "--help"]); + assert_eq!(output.code, 0, "gateway add --help:\n{}", output.combined); + + for expected in ["--name", "--remote", "--local"] { + assert!( + output.combined.contains(expected), + "expected '{expected}' in gateway add --help:\n{}", + output.combined + ); + } + assert!( + output.combined.contains("endpoint") || output.combined.contains(""), + "expected endpoint argument in gateway add --help:\n{}", + output.combined + ); +} + +#[test] +fn gateway_login_help_describes_authentication() { + let output = run_isolated(&["gateway", "login", "--help"]); + assert_eq!(output.code, 0, "gateway login --help:\n{}", output.combined); + + let help = output.combined.to_lowercase(); + assert!( + ["authenticat", "cloudflare", "login", "browser"] + .iter() + .any(|term| help.contains(term)), + "expected auth-related gateway login help:\n{}", + output.combined + ); +} + +#[test] +fn removed_gateway_lifecycle_subcommands_fail_to_parse() { + for command in ["start", "stop", "destroy"] { + let output = run_isolated(&["gateway", command, "--help"]); + assert_ne!( + output.code, 0, + "gateway {command} should fail after lifecycle command removal" + ); + assert!( + output.combined.contains("unrecognized subcommand") + || output.combined.contains("error:"), + "expected parser error for gateway {command}:\n{}", + output.combined + ); + } +} + +#[test] +fn gateway_add_rejects_conflicting_type_flags() { + let conflicting = run_isolated(&[ + "gateway", + "add", + "https://example.com", + "--remote", + "user@host", + "--local", + ]); + assert_ne!( + conflicting.code, 0, + "--remote and --local should conflict:\n{}", + conflicting.combined + ); +} + +#[test] +fn gateway_add_rejects_removed_ssh_key_flag() { + let removed = run_isolated(&[ + "gateway", + "add", + "https://example.com", + "--ssh-key", + "/tmp/fake-key", + ]); + assert_ne!( + removed.code, 0, + "removed --ssh-key flag should fail:\n{}", + removed.combined + ); +} diff --git a/crates/openshell-cli/tests/cloudflare_gateway_integration.rs b/crates/openshell-cli/tests/cloudflare_gateway_integration.rs new file mode 100644 index 0000000000..6b349aef17 --- /dev/null +++ b/crates/openshell-cli/tests/cloudflare_gateway_integration.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cloudflare gateway registration behavior that needs no external network. + +mod common; + +use common::{run, run_isolated}; + +#[test] +fn gateway_add_creates_cloudflare_metadata_and_selects_gateway() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + + let output = run( + config_dir.path(), + system_dir.path(), + &[ + "gateway", + "add", + "https://my-gateway.example.com", + "--name", + "test-cf-gw", + ], + ); + assert_eq!(output.code, 0, "gateway add:\n{}", output.combined); + + let metadata_path = config_dir + .path() + .join("openshell/gateways/test-cf-gw/metadata.json"); + let metadata: serde_json::Value = serde_json::from_slice( + &std::fs::read(&metadata_path).expect("read Cloudflare gateway metadata"), + ) + .expect("parse Cloudflare gateway metadata"); + assert_eq!(metadata["auth_mode"], "cloudflare_jwt"); + assert_eq!( + metadata["gateway_endpoint"], + "https://my-gateway.example.com" + ); + assert_eq!(metadata["name"], "test-cf-gw"); + assert_eq!(metadata["is_remote"], true); + + let active = std::fs::read_to_string(config_dir.path().join("openshell/active_gateway")) + .expect("read active gateway"); + assert_eq!(active.trim(), "test-cf-gw"); + assert!( + output.combined.contains("test-cf-gw") && output.combined.contains("added"), + "{}", + output.combined + ); +} + +#[test] +fn gateway_add_derives_cloudflare_name_from_hostname() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + + let output = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "https://my-special-gateway.brevlab.com"], + ); + assert_eq!(output.code, 0, "gateway add:\n{}", output.combined); + assert!( + config_dir + .path() + .join("openshell/gateways/my-special-gateway.brevlab.com/metadata.json") + .exists() + ); +} + +#[test] +fn ssh_gateway_shorthand_conflicts_with_local_type() { + let local = run_isolated(&["gateway", "add", "ssh://user@host:8080", "--local"]); + assert_ne!(local.code, 0, "ssh:// with --local should fail"); +} + +#[test] +fn ssh_gateway_shorthand_conflicts_with_explicit_remote() { + let remote = run_isolated(&[ + "gateway", + "add", + "ssh://user@host:8080", + "--remote", + "user@host", + ]); + assert_ne!(remote.code, 0, "ssh:// with --remote should fail"); +} + +#[test] +fn ssh_gateway_shorthand_requires_port() { + let output = run_isolated(&["gateway", "add", "ssh://user@host"]); + assert_ne!(output.code, 0, "ssh:// without port should fail"); + assert!(output.combined.contains("port"), "{}", output.combined); +} diff --git a/crates/openshell-cli/tests/common/mod.rs b/crates/openshell-cli/tests/common/mod.rs new file mode 100644 index 0000000000..b86741d8b6 --- /dev/null +++ b/crates/openshell-cli/tests/common/mod.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; +use std::process::{Command, Stdio}; + +pub struct CliOutput { + #[allow(dead_code)] + pub stdout: String, + pub combined: String, + pub code: i32, +} + +pub fn run(config_dir: &Path, system_dir: &Path, args: &[&str]) -> CliOutput { + let output = Command::new(env!("CARGO_BIN_EXE_openshell")) + .args(args) + .env("XDG_CONFIG_HOME", config_dir) + .env("HOME", config_dir) + .env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir) + .env("OPENSHELL_NO_BROWSER", "1") + .env_remove("OPENSHELL_GATEWAY") + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .stdin(Stdio::null()) + .output() + .expect("run openshell"); + + let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); + let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8"); + CliOutput { + combined: format!("{stdout}{stderr}"), + stdout, + code: output.status.code().unwrap_or(-1), + } +} + +pub fn run_isolated(args: &[&str]) -> CliOutput { + let config_dir = tempfile::tempdir().expect("create isolated user config dir"); + let system_dir = tempfile::tempdir().expect("create isolated system config dir"); + run(config_dir.path(), system_dir.path(), args) +} diff --git a/crates/openshell-cli/tests/gateway_registration_integration.rs b/crates/openshell-cli/tests/gateway_registration_integration.rs new file mode 100644 index 0000000000..28688a4ea5 --- /dev/null +++ b/crates/openshell-cli/tests/gateway_registration_integration.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway registration and configuration behavior that needs no live gateway. + +mod common; + +use std::path::Path; + +use common::{run, run_isolated}; + +fn write_gateway_metadata( + root: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + let gateway_dir = root.join("gateways").join(name); + std::fs::create_dir_all(&gateway_dir).expect("create gateway dir"); + let metadata = serde_json::json!({ + "name": name, + "gateway_endpoint": endpoint, + "gateway_port": gateway_port, + "is_remote": is_remote, + "auth_mode": auth_mode, + }); + std::fs::write( + gateway_dir.join("metadata.json"), + serde_json::to_vec_pretty(&metadata).expect("serialize gateway metadata"), + ) + .expect("write gateway metadata"); +} + +fn write_user_gateway_metadata( + config_dir: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + write_gateway_metadata( + &config_dir.join("openshell"), + name, + endpoint, + gateway_port, + is_remote, + auth_mode, + ); +} + +fn write_active_gateway(config_dir: &Path, name: &str) { + let active_path = config_dir.join("openshell").join("active_gateway"); + std::fs::create_dir_all(active_path.parent().expect("active gateway parent")) + .expect("create active gateway parent"); + std::fs::write(active_path, format!("{name}\n")).expect("write active gateway"); +} + +fn seed_gateway_sources(config_dir: &Path, system_dir: &Path) { + write_user_gateway_metadata( + config_dir, + "alpha", + "https://alpha.example.com", + 443, + true, + "cloudflare_jwt", + ); + write_gateway_metadata( + system_dir, + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); +} + +#[test] +fn status_without_gateway_prints_registration_hint() { + let output = run_isolated(&["status"]); + assert_eq!( + output.code, 0, + "status without a gateway should succeed:\n{}", + output.combined + ); + assert!(output.combined.contains("No gateway configured")); + assert!( + output.combined.contains("openshell gateway add "), + "{}", + output.combined + ); +} + +#[test] +fn gateway_list_table_shows_user_and_system_sources() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + seed_gateway_sources(config_dir.path(), system_dir.path()); + write_active_gateway(config_dir.path(), "alpha"); + + let output = run(config_dir.path(), system_dir.path(), &["gateway", "list"]); + assert_eq!(output.code, 0, "gateway list:\n{}", output.combined); + assert!(output.combined.contains("SOURCE"), "{}", output.combined); + + let alpha = output + .combined + .lines() + .find(|line| line.contains("alpha")) + .expect("find alpha row"); + assert!(alpha.contains("user"), "{}", output.combined); + + let beta = output + .combined + .lines() + .find(|line| line.contains("beta")) + .expect("find beta row"); + assert!(beta.contains("system"), "{}", output.combined); +} + +#[test] +fn gateway_list_json_includes_user_and_system_sources() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + seed_gateway_sources(config_dir.path(), system_dir.path()); + + let output = run( + config_dir.path(), + system_dir.path(), + &["gateway", "list", "-o", "json"], + ); + assert_eq!(output.code, 0, "gateway list -o json:\n{}", output.combined); + + let items: serde_json::Value = + serde_json::from_str(&output.stdout).expect("parse gateway list JSON"); + let items = items.as_array().expect("gateway list JSON array"); + assert_eq!(items.len(), 2); + assert_eq!( + items.iter().find(|item| item["name"] == "alpha").unwrap()["source"], + "user" + ); + assert_eq!( + items.iter().find(|item| item["name"] == "beta").unwrap()["source"], + "system" + ); +} + +#[test] +fn user_registration_can_shadow_system_gateway() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + write_gateway_metadata( + system_dir.path(), + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); + + let added = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "http://127.0.0.1:17671", "--name", "beta"], + ); + assert_eq!(added.code, 0, "gateway add:\n{}", added.combined); + + let listed = run( + config_dir.path(), + system_dir.path(), + &["gateway", "list", "-o", "json"], + ); + let items: serde_json::Value = + serde_json::from_str(&listed.stdout).expect("parse gateway list JSON"); + let beta = items + .as_array() + .unwrap() + .iter() + .find(|item| item["name"] == "beta") + .unwrap(); + assert_eq!(beta["source"], "user"); + assert_eq!(beta["endpoint"], "http://127.0.0.1:17671"); +} + +#[test] +fn gateway_remove_rejects_system_registration_and_preserves_it() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + write_gateway_metadata( + system_dir.path(), + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); + + let removed = run( + config_dir.path(), + system_dir.path(), + &["gateway", "remove", "beta"], + ); + assert_ne!(removed.code, 0, "system gateway removal should fail"); + let normalized = removed + .combined + .replace(['│', '×'], " ") + .split_whitespace() + .collect::>() + .join(" "); + assert!( + normalized.contains("installed by the system and cannot be removed from user config"), + "{}", + removed.combined + ); + + let listed = run( + config_dir.path(), + system_dir.path(), + &["gateway", "list", "-o", "json"], + ); + let items: serde_json::Value = + serde_json::from_str(&listed.stdout).expect("parse gateway list JSON"); + let beta = items + .as_array() + .unwrap() + .iter() + .find(|item| item["name"] == "beta") + .unwrap(); + assert_eq!(beta["source"], "system"); + assert_eq!(beta["endpoint"], "http://127.0.0.1:17670"); +} + +#[test] +fn gateway_add_rejects_duplicate_user_name() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + + let first = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "http://127.0.0.1:1", "--name", "my-gw"], + ); + assert_eq!(first.code, 0, "first gateway add:\n{}", first.combined); + + let duplicate = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "http://127.0.0.1:2", "--name", "my-gw"], + ); + assert_ne!(duplicate.code, 0, "duplicate gateway add should fail"); + assert!( + duplicate.combined.contains("already exists"), + "{}", + duplicate.combined + ); +} diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index d4fa2a8103..bbbcfe6fa9 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -22,8 +22,6 @@ source "${ROOT}/e2e/support/conformance.sh" # stabilized and can be added here. PODMAN_CI_TESTS=( bypass_detection - cf_auth_smoke - cli_smoke core_dump_hardening credential_gating default_image diff --git a/e2e/rust/tests/cf_auth_smoke.rs b/e2e/rust/tests/cf_auth_smoke.rs deleted file mode 100644 index 34fa1be02c..0000000000 --- a/e2e/rust/tests/cf_auth_smoke.rs +++ /dev/null @@ -1,387 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! CLI smoke tests for Cloudflare tunnel auth commands. -//! -//! These tests do NOT require a running gateway — they exercise the CLI binary -//! directly, validating that the new Cloudflare-related commands and flags -//! parse correctly and behave as expected. - -use std::process::Stdio; - -use openshell_e2e::harness::binary::openshell_cmd; -use openshell_e2e::harness::output::strip_ansi; - -/// Run `openshell ` with an isolated (empty) config directory so it -/// cannot discover any real gateway. Returns (combined stdout+stderr, exit code). -async fn run_isolated(args: &[&str]) -> (String, i32) { - let tmpdir = tempfile::tempdir().expect("create isolated config dir"); - let mut cmd = openshell_cmd(); - cmd.args(args) - .env("XDG_CONFIG_HOME", tmpdir.path()) - .env("HOME", tmpdir.path()) - .env_remove("OPENSHELL_GATEWAY") - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") - // Suppress browser popup during auth flow. - .env("OPENSHELL_NO_BROWSER", "1") - // Use a closed stdin so auth prompts don't hang the test. - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let output = cmd.output().await.expect("spawn openshell"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - let code = output.status.code().unwrap_or(-1); - (combined, code) -} - -/// Run `openshell ` with a given tmpdir as config (for persisting state -/// across multiple commands). Returns (combined stdout+stderr, exit code). -async fn run_with_config(tmpdir: &std::path::Path, args: &[&str]) -> (String, i32) { - let mut cmd = openshell_cmd(); - cmd.args(args) - .env("XDG_CONFIG_HOME", tmpdir) - .env("HOME", tmpdir) - .env_remove("OPENSHELL_GATEWAY") - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") - // Suppress browser popup during auth flow. - .env("OPENSHELL_NO_BROWSER", "1") - // Use a closed stdin so auth prompts don't hang the test. - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let output = cmd.output().await.expect("spawn openshell"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - let code = output.status.code().unwrap_or(-1); - (combined, code) -} - -// ------------------------------------------------------------------- -// Test 8: gateway lifecycle commands are not exposed through the CLI -// ------------------------------------------------------------------- - -/// `openshell gateway --help` must not show removed lifecycle commands. -#[tokio::test] -async fn gateway_help_omits_lifecycle_commands() { - let (output, code) = run_isolated(&["gateway", "--help"]).await; - assert_eq!(code, 0, "gateway --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - for removed in ["start", "stop", "destroy"] { - assert!( - !clean.contains(removed), - "did not expect removed gateway lifecycle command '{removed}' in gateway help:\n{clean}" - ); - } -} - -// ------------------------------------------------------------------- -// Test 9: `gateway add` and `gateway login` are recognized -// ------------------------------------------------------------------- - -/// `openshell gateway --help` must list `add` and `login` subcommands. -#[tokio::test] -async fn gateway_help_shows_add_and_login() { - let (output, code) = run_isolated(&["gateway", "--help"]).await; - assert_eq!(code, 0, "gateway --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("add"), - "expected 'add' in gateway --help output:\n{clean}" - ); - assert!( - clean.contains("login"), - "expected 'login' in gateway --help output:\n{clean}" - ); -} - -/// `openshell gateway add --help` must show the endpoint arg and gateway-type flags. -#[tokio::test] -async fn gateway_add_help_shows_flags() { - let (output, code) = run_isolated(&["gateway", "add", "--help"]).await; - assert_eq!(code, 0, "gateway add --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("--name"), - "expected '--name' in gateway add --help:\n{clean}" - ); - assert!( - clean.contains("--remote"), - "expected '--remote' in gateway add --help:\n{clean}" - ); - assert!( - clean.contains("--local"), - "expected '--local' in gateway add --help:\n{clean}" - ); - assert!( - // The positional argument for the endpoint - clean.contains("endpoint") || clean.contains(""), - "expected endpoint argument in gateway add --help:\n{clean}" - ); -} - -/// `openshell gateway login --help` is recognized. -#[tokio::test] -async fn gateway_login_help_is_recognized() { - let (output, code) = run_isolated(&["gateway", "login", "--help"]).await; - assert_eq!(code, 0, "gateway login --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - // Should mention authenticating or Cloudflare - assert!( - clean.to_lowercase().contains("authenticat") - || clean.to_lowercase().contains("cloudflare") - || clean.to_lowercase().contains("login") - || clean.to_lowercase().contains("browser"), - "expected auth-related text in gateway login --help:\n{clean}" - ); -} - -// ------------------------------------------------------------------- -// Test 10: `gateway add` creates metadata with cloudflare_jwt -// ------------------------------------------------------------------- - -/// `openshell gateway add ` (cloud gateway) should: -/// - Create cluster metadata with `auth_mode` = `"cloudflare_jwt"` -/// - Set the gateway as active -/// - Attempt browser authentication (which will fail in CI — non-fatal) -#[tokio::test] -async fn gateway_add_creates_cf_metadata() { - let tmpdir = tempfile::tempdir().expect("create config dir"); - - let (output, code) = run_with_config( - tmpdir.path(), - &[ - "gateway", - "add", - "https://my-gateway.example.com", - "--name", - "test-cf-gw", - ], - ) - .await; - - assert_eq!( - code, 0, - "gateway add should exit 0 (auth failure is non-fatal):\n{output}" - ); - - // Verify the metadata file was written. - let metadata_path = tmpdir - .path() - .join("openshell") - .join("gateways") - .join("test-cf-gw") - .join("metadata.json"); - assert!( - metadata_path.exists(), - "metadata file should exist at {}", - metadata_path.display() - ); - - let metadata_content = std::fs::read_to_string(&metadata_path).expect("read metadata"); - let metadata: serde_json::Value = - serde_json::from_str(&metadata_content).expect("parse metadata JSON"); - - assert_eq!( - metadata["auth_mode"].as_str(), - Some("cloudflare_jwt"), - "auth_mode should be 'cloudflare_jwt', got: {metadata_content}" - ); - assert_eq!( - metadata["gateway_endpoint"].as_str(), - Some("https://my-gateway.example.com"), - "gateway_endpoint should match the provided URL" - ); - assert_eq!( - metadata["name"].as_str(), - Some("test-cf-gw"), - "name should match --name flag" - ); - assert_eq!( - metadata["is_remote"].as_bool(), - Some(true), - "CF gateway should be marked as remote" - ); - - // Verify the gateway was set as active. - let active_path = tmpdir.path().join("openshell").join("active_gateway"); - assert!( - active_path.exists(), - "active_gateway file should exist at {}", - active_path.display() - ); - let active = std::fs::read_to_string(&active_path).expect("read active_gateway"); - assert_eq!( - active.trim(), - "test-cf-gw", - "active gateway should be 'test-cf-gw'" - ); - - // Verify the output mentions the gateway was added. - let clean = strip_ansi(&output); - assert!( - clean.contains("test-cf-gw") && clean.contains("added"), - "output should confirm gateway was added:\n{clean}" - ); -} - -/// `gateway add` without `--name` should derive a name from the hostname. -#[tokio::test] -async fn gateway_add_derives_name_from_hostname() { - let tmpdir = tempfile::tempdir().expect("create config dir"); - - let (output, code) = run_with_config( - tmpdir.path(), - &["gateway", "add", "https://my-special-gateway.brevlab.com"], - ) - .await; - - assert_eq!(code, 0, "gateway add should exit 0:\n{output}"); - - // The derived name should be the hostname. - let metadata_path = tmpdir - .path() - .join("openshell") - .join("gateways") - .join("my-special-gateway.brevlab.com") - .join("metadata.json"); - assert!( - metadata_path.exists(), - "metadata file should exist with hostname-derived name at {}", - metadata_path.display() - ); -} - -// ------------------------------------------------------------------- -// Test 11: `gateway add` flag constraints -// ------------------------------------------------------------------- - -/// `--remote` and `--local` are mutually exclusive. -#[tokio::test] -async fn gateway_add_remote_and_local_conflict() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "https://example.com", - "--remote", - "user@host", - "--local", - ]) - .await; - - assert_ne!( - code, 0, - "--remote and --local together should fail:\n{output}" - ); -} - -/// `--ssh-key` was removed from `gateway add`. -#[tokio::test] -async fn gateway_add_rejects_removed_ssh_key_flag() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "https://example.com", - "--ssh-key", - "/tmp/fake-key", - ]) - .await; - - assert_ne!( - code, 0, - "--ssh-key should fail after gateway lifecycle bootstrap removal:\n{output}" - ); -} - -// ------------------------------------------------------------------- -// Test 12: `gateway add` rejects duplicate names -// ------------------------------------------------------------------- - -/// Adding a gateway with a name that already exists should fail. -#[tokio::test] -async fn gateway_add_rejects_duplicate_name() { - let tmpdir = tempfile::tempdir().expect("create config dir"); - - // First add should succeed. - let (output, code) = run_with_config( - tmpdir.path(), - &[ - "gateway", - "add", - "https://first.example.com", - "--name", - "my-gw", - ], - ) - .await; - assert_eq!(code, 0, "first gateway add should succeed:\n{output}"); - - // Second add with the same name should fail. - let (output, code) = run_with_config( - tmpdir.path(), - &[ - "gateway", - "add", - "https://second.example.com", - "--name", - "my-gw", - ], - ) - .await; - assert_ne!(code, 0, "duplicate gateway add should fail:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("already exists"), - "error should mention 'already exists':\n{clean}" - ); -} - -// ------------------------------------------------------------------- -// Test 13: `gateway add ssh://` shorthand constraints -// ------------------------------------------------------------------- - -/// `ssh://` endpoint with `--local` should fail. -#[tokio::test] -async fn gateway_add_ssh_url_conflicts_with_local() { - let (output, code) = run_isolated(&["gateway", "add", "ssh://user@host:8080", "--local"]).await; - - assert_ne!(code, 0, "ssh:// with --local should fail:\n{output}"); -} - -/// `ssh://` endpoint with `--remote` should fail (redundant). -#[tokio::test] -async fn gateway_add_ssh_url_conflicts_with_remote() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "ssh://user@host:8080", - "--remote", - "user@host", - ]) - .await; - - assert_ne!(code, 0, "ssh:// with --remote should fail:\n{output}"); -} - -/// `ssh://` endpoint without a port should fail. -#[tokio::test] -async fn gateway_add_ssh_url_requires_port() { - let (output, code) = run_isolated(&["gateway", "add", "ssh://user@host"]).await; - - assert_ne!(code, 0, "ssh:// without port should fail:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("port"), - "error should mention port:\n{clean}" - ); -} diff --git a/e2e/rust/tests/cli_smoke.rs b/e2e/rust/tests/cli_smoke.rs deleted file mode 100644 index fe3d78b146..0000000000 --- a/e2e/rust/tests/cli_smoke.rs +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! CLI smoke tests that verify command structure and graceful error handling. -//! -//! These tests do NOT require a running gateway — they exercise the CLI binary -//! directly, validating that the restructured command tree parses correctly and -//! handles edge cases like missing gateway configuration. - -use std::fs; -use std::path::Path; -use std::process::Stdio; - -use openshell_e2e::harness::binary::openshell_cmd; -use openshell_e2e::harness::output::strip_ansi; - -async fn run_with_config( - config_dir: &Path, - system_dir: Option<&Path>, - args: &[&str], -) -> (String, i32) { - let mut cmd = openshell_cmd(); - cmd.args(args) - .env("XDG_CONFIG_HOME", config_dir) - .env("HOME", config_dir) - .env_remove("OPENSHELL_GATEWAY") - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - if let Some(system_dir) = system_dir { - cmd.env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir); - } else { - cmd.env_remove("OPENSHELL_SYSTEM_GATEWAY_DIR"); - } - - let output = cmd.output().await.expect("spawn openshell"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - let code = output.status.code().unwrap_or(-1); - (combined, code) -} - -/// Run `openshell ` with an isolated (empty) config directory so it -/// cannot discover any real gateway. -async fn run_isolated(args: &[&str]) -> (String, i32) { - let tmpdir = tempfile::tempdir().expect("create isolated config dir"); - let system_dir = tempfile::tempdir().expect("create isolated system config dir"); - run_with_config(tmpdir.path(), Some(system_dir.path()), args).await -} - -fn write_gateway_metadata( - root: &Path, - name: &str, - endpoint: &str, - gateway_port: u16, - is_remote: bool, - auth_mode: &str, -) { - let gateway_dir = root.join("gateways").join(name); - fs::create_dir_all(&gateway_dir).expect("create gateway dir"); - let metadata = serde_json::json!({ - "name": name, - "gateway_endpoint": endpoint, - "gateway_port": gateway_port, - "is_remote": is_remote, - "auth_mode": auth_mode, - }); - fs::write( - gateway_dir.join("metadata.json"), - serde_json::to_vec_pretty(&metadata).expect("serialize gateway metadata"), - ) - .expect("write gateway metadata"); -} - -fn write_user_gateway_metadata( - config_dir: &Path, - name: &str, - endpoint: &str, - gateway_port: u16, - is_remote: bool, - auth_mode: &str, -) { - write_gateway_metadata( - &config_dir.join("openshell"), - name, - endpoint, - gateway_port, - is_remote, - auth_mode, - ); -} - -fn write_system_gateway_metadata( - system_dir: &Path, - name: &str, - endpoint: &str, - gateway_port: u16, - is_remote: bool, - auth_mode: &str, -) { - write_gateway_metadata( - system_dir, - name, - endpoint, - gateway_port, - is_remote, - auth_mode, - ); -} - -fn write_active_gateway(config_dir: &Path, name: &str) { - let active_path = config_dir.join("openshell").join("active_gateway"); - fs::create_dir_all(active_path.parent().expect("active gateway parent")) - .expect("create active gateway parent"); - fs::write(active_path, format!("{name}\n")).expect("write active gateway"); -} - -fn seed_gateway_sources(config_dir: &Path, system_dir: &Path) { - write_user_gateway_metadata( - config_dir, - "alpha", - "https://alpha.example.com", - 443, - true, - "cloudflare_jwt", - ); - write_system_gateway_metadata( - system_dir, - "beta", - "http://127.0.0.1:17670", - 17670, - false, - "plaintext", - ); -} - -// ------------------------------------------------------------------- -// Top-level --help shows the restructured command tree -// ------------------------------------------------------------------- - -/// `openshell --help` must list the new top-level commands: gateway, status, -/// forward, logs, policy. -#[tokio::test] -async fn help_shows_restructured_commands() { - let (output, code) = run_isolated(&["--help"]).await; - assert_eq!(code, 0, "openshell --help should exit 0"); - - let clean = strip_ansi(&output); - for cmd in ["gateway", "status", "sandbox", "forward", "logs", "policy"] { - assert!( - clean.contains(cmd), - "expected '{cmd}' in --help output:\n{clean}" - ); - } -} - -/// `openshell gateway --help` must list registration/auth commands, not -/// service lifecycle commands. -#[tokio::test] -async fn gateway_help_shows_subcommands() { - let (output, code) = run_isolated(&["gateway", "--help"]).await; - assert_eq!(code, 0, "openshell gateway --help should exit 0"); - - let clean = strip_ansi(&output); - for sub in ["add", "remove", "login", "logout", "select", "info", "list"] { - assert!( - clean.contains(sub), - "expected '{sub}' in gateway --help output:\n{clean}" - ); - } - - for removed in ["start", "stop", "destroy"] { - assert!( - !clean.contains(removed), - "did not expect removed gateway lifecycle subcommand '{removed}' in help:\n{clean}" - ); - } -} - -/// `openshell sandbox --help` must list upload and download alongside create, -/// get, list, delete, connect. -#[tokio::test] -async fn sandbox_help_shows_upload_download() { - let (output, code) = run_isolated(&["sandbox", "--help"]).await; - assert_eq!(code, 0, "openshell sandbox --help should exit 0"); - - let clean = strip_ansi(&output); - for sub in [ - "upload", "download", "create", "get", "list", "delete", "connect", - ] { - assert!( - clean.contains(sub), - "expected '{sub}' in sandbox --help output:\n{clean}" - ); - } -} - -/// `openshell sandbox create --help` must show `--gpu`, `--upload`, -/// `--no-git-ignore`, `--editor`, and `--auto-providers`/`--no-auto-providers`. -#[tokio::test] -async fn sandbox_create_help_shows_new_flags() { - let (output, code) = run_isolated(&["sandbox", "create", "--help"]).await; - assert_eq!(code, 0, "openshell sandbox create --help should exit 0"); - - let clean = strip_ansi(&output); - for flag in [ - "--gpu", - "--upload", - "--no-git-ignore", - "--editor", - "--auto-providers", - "--no-auto-providers", - ] { - assert!( - clean.contains(flag), - "expected '{flag}' in sandbox create --help:\n{clean}" - ); - } -} - -/// `openshell sandbox connect --help` must show `--editor`. -#[tokio::test] -async fn sandbox_connect_help_shows_editor_flag() { - let (output, code) = run_isolated(&["sandbox", "connect", "--help"]).await; - assert_eq!(code, 0, "openshell sandbox connect --help should exit 0"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("--editor"), - "expected '--editor' in sandbox connect --help:\n{clean}" - ); -} - -/// Removed gateway lifecycle subcommands should fail during parsing. -#[tokio::test] -async fn gateway_lifecycle_subcommands_are_removed() { - for subcommand in ["start", "stop", "destroy"] { - let (output, code) = run_isolated(&["gateway", subcommand, "--help"]).await; - assert!( - code != 0, - "openshell gateway {subcommand} should fail after lifecycle command removal" - ); - - let clean = strip_ansi(&output); - assert!( - clean.contains("unrecognized subcommand") || clean.contains("error:"), - "expected parser error for removed gateway subcommand '{subcommand}':\n{clean}" - ); - } -} - -// ------------------------------------------------------------------- -// Graceful handling: `openshell status` without a gateway -// ------------------------------------------------------------------- - -/// `openshell status` with no gateway configured should exit 0 and print a -/// friendly message instead of erroring. -#[tokio::test] -async fn status_without_gateway_prints_friendly_message() { - let (output, code) = run_isolated(&["status"]).await; - assert_eq!( - code, 0, - "openshell status should exit 0 even without a gateway, got output:\n{output}" - ); - - let clean = strip_ansi(&output); - assert!( - clean.contains("No gateway configured"), - "expected 'No gateway configured' in status output:\n{clean}" - ); - assert!( - clean.contains("openshell gateway add "), - "expected hint to register a gateway:\n{clean}" - ); -} - -// ------------------------------------------------------------------- -// Gateway list source indicators -// ------------------------------------------------------------------- - -#[tokio::test] -async fn gateway_list_table_shows_user_and_system_sources() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - seed_gateway_sources(config_dir.path(), system_dir.path()); - write_active_gateway(config_dir.path(), "alpha"); - - let (output, code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list"], - ) - .await; - assert_eq!(code, 0, "gateway list should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - assert!(clean.contains("SOURCE"), "expected SOURCE column:\n{clean}"); - - let alpha_line = clean - .lines() - .find(|line| line.contains("alpha")) - .expect("find alpha row"); - assert!( - alpha_line.contains("user"), - "expected alpha row to show user source:\n{clean}" - ); - - let beta_line = clean - .lines() - .find(|line| line.contains("beta")) - .expect("find beta row"); - assert!( - beta_line.contains("system"), - "expected beta row to show system source:\n{clean}" - ); -} - -#[tokio::test] -async fn gateway_list_json_includes_user_and_system_sources() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - seed_gateway_sources(config_dir.path(), system_dir.path()); - - let (output, code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list", "-o", "json"], - ) - .await; - assert_eq!(code, 0, "gateway list -o json should exit 0:\n{output}"); - - let items: serde_json::Value = serde_json::from_str(&output).expect("parse gateway list json"); - let items = items.as_array().expect("gateway list json array"); - assert_eq!(items.len(), 2, "expected two gateways in json output"); - - let alpha = items - .iter() - .find(|item| item["name"] == "alpha") - .expect("find alpha entry"); - assert_eq!(alpha["source"], "user"); - - let beta = items - .iter() - .find(|item| item["name"] == "beta") - .expect("find beta entry"); - assert_eq!(beta["source"], "system"); -} - -#[tokio::test] -async fn gateway_add_can_shadow_system_gateway_with_user_registration() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - write_system_gateway_metadata( - system_dir.path(), - "beta", - "http://127.0.0.1:17670", - 17670, - false, - "plaintext", - ); - - let (add_output, add_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "add", "http://127.0.0.1:17671", "--name", "beta"], - ) - .await; - assert_eq!( - add_code, 0, - "gateway add should allow a user registration to shadow a system gateway:\n{add_output}" - ); - - let (list_output, list_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list", "-o", "json"], - ) - .await; - assert_eq!( - list_code, 0, - "gateway list -o json should exit 0:\n{list_output}" - ); - - let items: serde_json::Value = - serde_json::from_str(&list_output).expect("parse gateway list json"); - let beta = items - .as_array() - .expect("gateway list json array") - .iter() - .find(|item| item["name"] == "beta") - .expect("find beta entry"); - assert_eq!(beta["source"], "user"); - assert_eq!(beta["endpoint"], "http://127.0.0.1:17671"); -} - -#[tokio::test] -async fn gateway_remove_rejects_system_only_registration_and_preserves_entry() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - write_system_gateway_metadata( - system_dir.path(), - "beta", - "http://127.0.0.1:17670", - 17670, - false, - "plaintext", - ); - - let (remove_output, remove_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "remove", "beta"], - ) - .await; - assert_ne!( - remove_code, 0, - "gateway remove should reject system-only registrations:\n{remove_output}" - ); - let clean_remove = strip_ansi(&remove_output); - let normalized_remove = clean_remove - .replace(['│', '×'], " ") - .split_whitespace() - .collect::>() - .join(" "); - assert!( - normalized_remove - .contains("installed by the system and cannot be removed from user config"), - "expected system-only removal guidance:\n{clean_remove}" - ); - - let (list_output, list_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list", "-o", "json"], - ) - .await; - assert_eq!( - list_code, 0, - "gateway list -o json should still succeed:\n{list_output}" - ); - - let items: serde_json::Value = - serde_json::from_str(&list_output).expect("parse gateway list json"); - let beta = items - .as_array() - .expect("gateway list json array") - .iter() - .find(|item| item["name"] == "beta") - .expect("find beta entry after failed remove"); - assert_eq!(beta["source"], "system"); - assert_eq!(beta["endpoint"], "http://127.0.0.1:17670"); -}