use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
use turnkey_api_key_stamper::TurnkeyP256ApiKey;
use tvc::config::turnkey::{Config, KeyCurve, OrgConfig, StoredApiKey};
const NON_INTERACTIVE_ENV: &str = "TVC_NON_INTERACTIVE";
const LOCAL_API_BASE_URL: &str = "http://127.0.0.1:1";
fn generated_api_key() -> (String, String) {
let stamper = TurnkeyP256ApiKey::generate();
(
hex::encode(stamper.compressed_public_key()),
hex::encode(stamper.private_key()),
)
}
fn write_config(
home: &TempDir,
api_key_path: std::path::PathBuf,
operator_key_path: std::path::PathBuf,
last_operator_ids: Vec<String>,
) {
let turnkey_dir = home.path().join(".config").join("turnkey");
fs::create_dir_all(&turnkey_dir).unwrap();
let config = Config {
active_org: Some("test".to_string()),
orgs: HashMap::from([(
"test".to_string(),
OrgConfig {
id: "org-test".to_string(),
api_key_path,
operator_key_path,
api_base_url: LOCAL_API_BASE_URL.to_string(),
},
)]),
last_created_app_id: HashMap::new(),
last_operator_ids: HashMap::from([("test".to_string(), last_operator_ids)]),
};
fs::write(
turnkey_dir.join("tvc.config.toml"),
toml::to_string_pretty(&config).unwrap(),
)
.unwrap();
}
fn write_api_key(path: &std::path::Path) {
let (public_key, private_key) = generated_api_key();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
path,
serde_json::to_string_pretty(&StoredApiKey {
public_key,
private_key,
curve: KeyCurve::P256,
})
.unwrap(),
)
.unwrap();
}
#[test]
fn login_without_org_errors_when_non_interactive_forced() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("login")
.assert()
.failure()
.stderr(predicate::str::contains(
"--org is required in non-interactive mode",
))
.stderr(predicate::str::contains(NON_INTERACTIVE_ENV));
}
#[test]
fn login_without_org_errors_with_non_interactive_flag() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.arg("--non-interactive")
.arg("login")
.assert()
.failure()
.stderr(predicate::str::contains(
"--org is required in non-interactive mode",
))
.stderr(predicate::str::contains("--non-interactive"));
}
#[test]
fn login_non_interactive_requires_existing_api_key_before_generating_one() {
let temp = TempDir::new().unwrap();
let org_dir = temp
.path()
.join(".config")
.join("turnkey")
.join("orgs")
.join("test");
let api_key_path = org_dir.join("api_key.json");
let operator_key_path = org_dir.join("operator.json");
write_config(&temp, api_key_path.clone(), operator_key_path, vec![]);
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("login")
.arg("--org")
.arg("test")
.assert()
.failure()
.stderr(predicate::str::contains("API key"))
.stderr(predicate::str::contains("non-interactive"));
assert!(
!api_key_path.exists(),
"non-interactive login must not generate an API key"
);
}
#[test]
fn approve_without_skip_interactive_errors_when_non_interactive_forced() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("deploy")
.arg("approve")
.arg("--manifest")
.arg("fixtures/manifest.json")
.arg("--operator-seed")
.arg("fixtures/seed.hex")
.arg("--skip-post")
.assert()
.failure()
.stderr(predicate::str::contains(
"--dangerous-skip-interactive is required in non-interactive mode",
))
.stderr(predicate::str::contains(NON_INTERACTIVE_ENV));
}
#[test]
fn deploy_init_interactive_conflicts_with_non_interactive_env() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.current_dir(temp.path())
.arg("deploy")
.arg("init")
.arg("--interactive")
.assert()
.failure()
.stderr(predicate::str::contains(
"--interactive conflicts with --non-interactive or TVC_NON_INTERACTIVE",
));
}
#[test]
fn deploy_init_interactive_conflicts_with_non_interactive_flag() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.current_dir(temp.path())
.arg("deploy")
.arg("init")
.arg("--interactive")
.arg("--non-interactive")
.assert()
.failure()
.stderr(predicate::str::contains(
"--interactive conflicts with --non-interactive or TVC_NON_INTERACTIVE",
));
}
#[test]
fn non_interactive_env_zero_does_not_force_non_interactive_mode() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "0")
.current_dir(temp.path())
.arg("deploy")
.arg("init")
.arg("--interactive")
.assert()
.failure()
.stderr(predicate::str::contains("--interactive requires a TTY"))
.stderr(predicate::str::contains("conflicts").not());
}
#[test]
fn invalid_non_interactive_env_value_errors_during_cli_parse() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "definitely")
.arg("login")
.arg("--org")
.arg("default")
.assert()
.failure()
.stderr(predicate::str::contains("value was not a boolean"));
}
#[test]
fn app_init_interactive_conflicts_with_non_interactive_env() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.current_dir(temp.path())
.arg("app")
.arg("init")
.arg("--interactive")
.assert()
.failure()
.stderr(predicate::str::contains(
"--interactive conflicts with --non-interactive or TVC_NON_INTERACTIVE",
));
}
#[test]
fn deploy_create_without_required_fields_bails_naming_each_field() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("deploy")
.arg("create")
.assert()
.failure()
.stderr(predicate::str::contains("app_id"))
.stderr(predicate::str::contains("qos_version"))
.stderr(predicate::str::contains("pivot_container_image_url"))
.stderr(predicate::str::contains("pivot_path"))
.stderr(predicate::str::contains("expected_pivot_digest"));
}
#[test]
fn deploy_create_pull_secret_placeholder_bails_when_non_interactive() {
let temp = TempDir::new().unwrap();
let config = r#"{
"appId": "file-app-id",
"qosVersion": "file-qos",
"pivotContainerImageUrl": "file-image",
"pivotPath": "file-path",
"pivotArgs": ["a", "b"],
"expectedPivotDigest": "file-digest",
"pivotContainerEncryptedPullSecret": "<REMOVE_ME_IF_PIVOT_CONTAINER_URL_IS_PUBLIC>",
"healthCheckType": "TVC_HEALTH_CHECK_TYPE_HTTP",
"healthCheckPort": 4000,
"publicIngressPort": 5000
}"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(config.as_bytes()).unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("deploy")
.arg("create")
.arg("--config-file")
.arg(file.path())
.assert()
.failure()
.stderr(predicate::str::contains(
"pivotContainerEncryptedPullSecret",
))
.stderr(predicate::str::contains("--pivot-pull-secret"));
}
#[test]
fn approve_dangerous_skip_interactive_bypasses_prompts_when_non_interactive_forced() {
let temp = TempDir::new().unwrap();
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("deploy")
.arg("approve")
.arg("--manifest")
.arg("fixtures/manifest.json")
.arg("--operator-seed")
.arg("fixtures/seed.hex")
.arg("--dangerous-skip-interactive")
.arg("--skip-post")
.assert()
.success();
}
#[test]
fn approve_non_interactive_requires_operator_id_when_saved_ids_are_ambiguous() {
let temp = TempDir::new().unwrap();
let org_dir = temp
.path()
.join(".config")
.join("turnkey")
.join("orgs")
.join("test");
let api_key_path = org_dir.join("api_key.json");
let operator_key_path = org_dir.join("operator.json");
write_config(
&temp,
api_key_path.clone(),
operator_key_path,
vec!["operator-1".to_string(), "operator-2".to_string()],
);
write_api_key(&api_key_path);
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.env(NON_INTERACTIVE_ENV, "1")
.arg("deploy")
.arg("approve")
.arg("--manifest")
.arg("fixtures/manifest.json")
.arg("--operator-seed")
.arg("fixtures/seed.hex")
.arg("--dangerous-skip-interactive")
.arg("--manifest-id")
.arg("manifest-id")
.assert()
.failure()
.stderr(predicate::str::contains("--operator-id"))
.stderr(predicate::str::contains("multiple"));
}
#[test]
fn deploy_init_template_does_not_require_readable_existing_config() {
let temp = TempDir::new().unwrap();
let turnkey_dir = temp.path().join(".config").join("turnkey");
fs::create_dir_all(&turnkey_dir).unwrap();
fs::write(turnkey_dir.join("tvc.config.toml"), "not valid toml").unwrap();
let output = temp.path().join("deploy.json");
cargo_bin_cmd!("tvc")
.env("HOME", temp.path())
.arg("deploy")
.arg("init")
.arg("--output")
.arg(&output)
.assert()
.success()
.stdout(predicate::str::contains("Port guidance:"))
.stdout(predicate::str::contains(
"Use the same port for both unless your binary exposes health checks",
));
assert!(output.exists(), "deploy init should write the template");
}