use clientele::SysexitsError::*;
use indoc::indoc;
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
};
use temp_dir::TempDir;
type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
const KEY_ENV: &str = "ASIMOV_TEST_MODULE_CONFIG_KEY";
const HOST_ENV: &str = "ASIMOV_TEST_MODULE_CONFIG_HOST";
const MANIFEST: &str = indoc! {r#"
{
"name": "demo",
"config": {
"variables": [
{
"name": "api-key",
"secret": true,
"environment": "ASIMOV_TEST_MODULE_CONFIG_KEY"
},
{
"name": "host",
"environment": "ASIMOV_TEST_MODULE_CONFIG_HOST",
"default_value": "default.example"
}
]
}
}
"#};
struct Sandbox(TempDir);
struct Run {
code: i32,
stdout: String,
}
impl Sandbox {
fn new() -> Result<Self> {
Self::with_manifest(MANIFEST)
}
fn with_manifest(manifest: &str) -> Result<Self> {
let dir = TempDir::new()?;
let module_dir = dir.child("modules/installed/demo");
std::fs::create_dir_all(&module_dir)?;
std::fs::write(module_dir.join("manifest.json"), manifest)?;
Ok(Self(dir))
}
fn root(&self) -> &Path {
self.0.path()
}
fn value_file(&self, key: &str) -> PathBuf {
self.root()
.join("configs")
.join("default")
.join("demo")
.join(key)
}
fn config(&self, args: &[&str]) -> Result<Run> {
self.config_env(args, &[])
}
fn config_env(&self, args: &[&str], env: &[(&str, &str)]) -> Result<Run> {
let mut all = vec!["config"];
all.extend_from_slice(args);
self.module_env(&all, env)
}
fn module(&self, args: &[&str]) -> Result<Run> {
self.module_env(args, &[])
}
fn module_env(&self, args: &[&str], env: &[(&str, &str)]) -> Result<Run> {
let mut command = Command::new(env!("CARGO_BIN_EXE_asimov"));
command
.arg("module")
.args(args)
.env("ASIMOV_ROOT", self.root())
.env_remove(KEY_ENV)
.env_remove(HOST_ENV)
.envs(env.iter().copied())
.stdin(Stdio::null());
let output = command.output()?;
Ok(Run {
code: output.status.code().expect("should exit normally"),
stdout: String::from_utf8(output.stdout)?,
})
}
}
#[test]
fn unset_cannot_remove_files_outside_the_configuration_directory() -> Result {
let sandbox = Sandbox::new()?;
let victim = sandbox.root().join("victim");
std::fs::write(&victim, "keep me")?;
for key in [
victim.to_str().expect("path should be UTF-8"),
"../../victim",
] {
let run = sandbox.config(&["unset", "demo", key])?;
assert_eq!(run.code, EX_USAGE as i32, "should reject `{key}`");
assert!(victim.exists(), "`{key}` removed a file outside the config");
}
Ok(())
}
#[test]
fn a_manifest_declaring_an_unusable_variable_name_is_rejected() -> Result {
let sandbox = Sandbox::with_manifest(indoc! {r#"
{
"name": "demo",
"config": { "variables": [{ "name": "../escape" }] }
}
"#})?;
let escape = sandbox.root().join("escape");
std::fs::write(&escape, "keep me")?;
for args in [
vec!["show", "demo"],
vec!["set", "demo", "../escape=value"],
vec!["unset", "demo", "--all"],
] {
let run = sandbox.config(&args)?;
assert_eq!(run.code, EX_DATAERR as i32, "should reject {args:?}");
}
assert_eq!(std::fs::read_to_string(&escape)?, "keep me");
Ok(())
}
#[test]
fn secret_values_are_shown_only_when_read_by_name() -> Result {
let sandbox = Sandbox::new()?;
sandbox.config(&["set", "demo", "api-key=s3cret-value"])?;
let shown = sandbox.config(&["show", "demo"])?;
assert!(
!shown.stdout.contains("s3cret-value"),
"`show` disclosed a secret: {}",
shown.stdout
);
assert!(shown.stdout.contains("api-key"), "`show` omitted the name");
let got = sandbox.config(&["get", "demo", "api-key"])?;
assert_eq!(got.stdout.trim(), "s3cret-value");
Ok(())
}
#[test]
fn a_rejected_batch_changes_nothing() -> Result {
let sandbox = Sandbox::new()?;
sandbox.config(&["set", "demo", "host=first"])?;
let run = sandbox.config(&["set", "demo", "host=second", "nonexistent=value"])?;
assert_eq!(run.code, EX_USAGE as i32);
let host = sandbox.config(&["get", "demo", "host", "--stored"])?;
assert_eq!(host.stdout.trim(), "first", "a rejected batch was applied");
Ok(())
}
#[cfg(unix)]
#[test]
fn stored_values_are_private_to_the_user() -> Result {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new()?;
sandbox.config(&["set", "demo", "api-key=s3cret-value"])?;
let mode =
|path: &Path| -> Result<u32> { Ok(std::fs::metadata(path)?.permissions().mode() & 0o777) };
assert_eq!(mode(&sandbox.value_file("api-key"))?, 0o600);
assert_eq!(mode(&sandbox.root().join("configs/default/demo"))?, 0o700);
Ok(())
}
#[cfg(unix)]
#[test]
fn a_failed_write_still_repairs_existing_configuration_permissions() -> Result {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new()?;
let demo_dir = sandbox.root().join("configs/default/demo");
let old_value = demo_dir.join("api-key");
let invalid_value = demo_dir.join("host");
std::fs::create_dir_all(&invalid_value)?;
std::fs::write(&old_value, "old-secret")?;
std::fs::set_permissions(&demo_dir, std::fs::Permissions::from_mode(0o755))?;
std::fs::set_permissions(&old_value, std::fs::Permissions::from_mode(0o644))?;
let run = sandbox.config(&["set", "demo", "host=example.test"])?;
assert_ne!(run.code, EX_OK as i32);
let mode =
|path: &Path| -> Result<u32> { Ok(std::fs::metadata(path)?.permissions().mode() & 0o777) };
assert_eq!(mode(&demo_dir)?, 0o700);
assert_eq!(mode(&old_value)?, 0o600);
Ok(())
}
#[cfg(unix)]
#[test]
fn setting_a_value_repairs_only_that_modules_configuration_permissions() -> Result {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new()?;
let profile_dir = sandbox.root().join("configs/default");
let demo_dir = profile_dir.join("demo");
let nested_dir = demo_dir.join("nested");
let other_dir = profile_dir.join("other");
std::fs::create_dir_all(&nested_dir)?;
std::fs::create_dir_all(&other_dir)?;
let old_value = demo_dir.join("api-key");
let nested_value = nested_dir.join("old");
let other_value = other_dir.join("key");
std::fs::write(&old_value, "old-secret")?;
std::fs::write(&nested_value, "old-secret")?;
std::fs::write(&other_value, "other-secret")?;
for dir in [&profile_dir, &demo_dir, &nested_dir, &other_dir] {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755))?;
}
for file in [&old_value, &nested_value, &other_value] {
std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o644))?;
}
sandbox.config(&["set", "demo", "host=example.test"])?;
let mode = |path: &Path| -> Result<u32> {
Ok(std::fs::symlink_metadata(path)?.permissions().mode() & 0o777)
};
assert_eq!(mode(&demo_dir)?, 0o700);
assert_eq!(mode(&nested_dir)?, 0o700);
assert_eq!(mode(&old_value)?, 0o600);
assert_eq!(mode(&nested_value)?, 0o600);
assert_eq!(mode(&sandbox.value_file("host"))?, 0o600);
assert_eq!(mode(&profile_dir)?, 0o755);
assert_eq!(mode(&other_dir)?, 0o755);
assert_eq!(mode(&other_value)?, 0o644);
Ok(())
}
#[test]
fn get_resolves_the_environment_then_the_stored_value_then_the_default() -> Result {
let sandbox = Sandbox::new()?;
let run = sandbox.config(&["get", "demo", "host"])?;
assert_eq!(run.stdout.trim(), "default.example");
sandbox.config(&["set", "demo", "host=stored.example"])?;
let run = sandbox.config(&["get", "demo", "host"])?;
assert_eq!(run.stdout.trim(), "stored.example");
let env = [(HOST_ENV, "env.example")];
let run = sandbox.config_env(&["get", "demo", "host"], &env)?;
assert_eq!(run.stdout.trim(), "env.example");
let run = sandbox.config_env(&["get", "demo", "host", "--stored"], &env)?;
assert_eq!(run.stdout.trim(), "stored.example");
Ok(())
}
#[test]
fn inspect_reports_unmet_configuration_through_its_exit_status() -> Result {
let sandbox = Sandbox::new()?;
let run = sandbox.module(&["inspect", "demo"])?;
assert_eq!(
run.code, EX_CONFIG as i32,
"`api-key` is required and unset"
);
assert!(run.stdout.contains("host"));
sandbox.config(&["set", "demo", "api-key=s3cret-value"])?;
let run = sandbox.module(&["inspect", "demo"])?;
assert_eq!(run.code, EX_OK as i32);
sandbox.config(&["unset", "demo", "api-key"])?;
let run = sandbox.module_env(&["inspect", "demo"], &[(KEY_ENV, "from-env")])?;
assert_eq!(run.code, EX_OK as i32);
Ok(())
}
#[test]
fn setup_without_a_terminal_fails_rather_than_waiting() -> Result {
let sandbox = Sandbox::new()?;
let run = sandbox.config(&["setup", "demo"])?;
assert_eq!(run.code, EX_UNAVAILABLE as i32);
Ok(())
}