use sysknife_daemon::actions::{ssh, ActionMechanism};
use sysknife_daemon::executor::execute_spec;
use tempfile::tempdir;
const TEST_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKeyForSysknifeUnitTestsDoNotUse testuser@sysknife-test";
const OTHER_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherKeyForSysknifeUnitTestsOnly other@sysknife-test";
const USERNAME: &str = "testuser";
const WILDCARD_KEY: &str = "ssh-ed25519 .*";
fn redirect_spec_path(
mut spec: sysknife_daemon::actions::ActionSpec,
temp_path: &str,
) -> sysknife_daemon::actions::ActionSpec {
if let ActionMechanism::Command {
ref mut program,
ref mut args,
..
} = spec.mechanism
{
assert_eq!(*program, "sudo");
assert_eq!(args[0], "/usr/lib/sysknife/action-steps");
assert!(matches!(args[1].as_str(), "ssh-add" | "ssh-remove"));
assert_eq!(args[2], USERNAME);
assert_eq!(args.len(), 4);
let helper = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../packaging/sysknife-action-steps");
let operation = args[1].clone();
let key = args[3].clone();
*program = "python3";
*args = vec!["-c".into(),
"import runpy,sys; m=runpy.run_path(sys.argv[1]); m['edit_key'](sys.argv[2],sys.argv[3],sys.argv[4]=='ssh-add')".into(),
helper.to_string_lossy().into_owned(), temp_path.into(), key, operation];
}
spec
}
#[tokio::test]
async fn add_authorized_key_appends_key_to_existing_empty_file() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, "").unwrap();
let spec = redirect_spec_path(ssh::add_authorized_key(USERNAME, TEST_KEY), &keys_path);
let out = execute_spec(&spec).await.unwrap();
assert_eq!(out.exit_code, 0);
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
content.contains(TEST_KEY),
"key must appear in authorized_keys after add: {content:?}"
);
}
#[tokio::test]
async fn add_authorized_key_creates_file_when_absent() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
let spec = redirect_spec_path(ssh::add_authorized_key(USERNAME, TEST_KEY), &keys_path);
let out = execute_spec(&spec).await.unwrap();
assert_eq!(out.exit_code, 0);
assert!(
std::path::Path::new(&keys_path).exists(),
"authorized_keys must be created when absent"
);
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
content.contains(TEST_KEY),
"key must be in newly created file: {content:?}"
);
}
#[tokio::test]
async fn add_authorized_key_is_idempotent() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, format!("{TEST_KEY}\n")).unwrap();
let spec = redirect_spec_path(ssh::add_authorized_key(USERNAME, TEST_KEY), &keys_path);
execute_spec(&spec).await.unwrap();
let content = std::fs::read_to_string(&keys_path).unwrap();
let count = content.lines().filter(|line| *line == TEST_KEY).count();
assert_eq!(
count, 1,
"key must appear exactly once after idempotent add: {content:?}"
);
}
#[tokio::test]
async fn add_authorized_key_preserves_other_keys() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, format!("{OTHER_KEY}\n")).unwrap();
let spec = redirect_spec_path(ssh::add_authorized_key(USERNAME, TEST_KEY), &keys_path);
execute_spec(&spec).await.unwrap();
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
content.contains(OTHER_KEY),
"pre-existing key must not be removed: {content:?}"
);
assert!(
content.contains(TEST_KEY),
"new key must also be present: {content:?}"
);
}
#[tokio::test]
async fn remove_authorized_key_deletes_exact_matching_line() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, format!("{TEST_KEY}\n")).unwrap();
let spec = redirect_spec_path(ssh::remove_authorized_key(USERNAME, TEST_KEY), &keys_path);
let out = execute_spec(&spec).await.unwrap();
assert_eq!(out.exit_code, 0);
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
!content.contains(TEST_KEY),
"removed key must not remain in authorized_keys: {content:?}"
);
}
#[tokio::test]
async fn remove_authorized_key_preserves_other_keys() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, format!("{TEST_KEY}\n{OTHER_KEY}\n")).unwrap();
let spec = redirect_spec_path(ssh::remove_authorized_key(USERNAME, TEST_KEY), &keys_path);
execute_spec(&spec).await.unwrap();
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
!content.contains(TEST_KEY),
"target key must be removed: {content:?}"
);
assert!(
content.contains(OTHER_KEY),
"other key must remain untouched: {content:?}"
);
}
#[tokio::test]
async fn remove_authorized_key_treats_the_key_as_a_literal_not_a_pattern() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, format!("{TEST_KEY}\n{OTHER_KEY}\n")).unwrap();
let spec = redirect_spec_path(
ssh::remove_authorized_key(USERNAME, WILDCARD_KEY),
&keys_path,
);
let out = execute_spec(&spec).await.unwrap();
assert_eq!(out.exit_code, 0, "no-op removal must still exit 0");
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
content.contains(TEST_KEY),
"a wildcard-shaped key must not delete an unrelated key: {content:?}"
);
assert!(
content.contains(OTHER_KEY),
"a wildcard-shaped key must not delete an unrelated key: {content:?}"
);
}
#[tokio::test]
async fn remove_authorized_key_passes_key_only_as_fixed_helper_data() {
let spec = ssh::remove_authorized_key(USERNAME, TEST_KEY);
let ActionMechanism::Command { program, args, .. } = &spec.mechanism else {
panic!("remove_authorized_key must use a Command mechanism");
};
assert_eq!(*program, "sudo");
assert_eq!(
args,
&[
"/usr/lib/sysknife/action-steps",
"ssh-remove",
USERNAME,
TEST_KEY
]
);
}
#[tokio::test]
async fn remove_authorized_key_is_noop_when_key_absent() {
let dir = tempdir().unwrap();
let keys_path = dir
.path()
.join("authorized_keys")
.to_string_lossy()
.into_owned();
std::fs::write(&keys_path, format!("{OTHER_KEY}\n")).unwrap();
let spec = redirect_spec_path(
ssh::remove_authorized_key(USERNAME, TEST_KEY), &keys_path,
);
let out = execute_spec(&spec).await.unwrap();
assert_eq!(
out.exit_code, 0,
"remove when key is absent must exit 0 (no-op)"
);
let content = std::fs::read_to_string(&keys_path).unwrap();
assert!(
content.contains(OTHER_KEY),
"unrelated key must not be affected by no-op remove: {content:?}"
);
}
#[test]
fn key_edits_drop_to_the_target_user() {
for spec in [
ssh::add_authorized_key(USERNAME, TEST_KEY),
ssh::remove_authorized_key(USERNAME, TEST_KEY),
] {
let ActionMechanism::Command { program, args } = &spec.mechanism else {
panic!("expected a Command mechanism");
};
assert_eq!(*program, "sudo", "{}", spec.action_name);
assert_eq!(
&args[0..3],
&[
"/usr/lib/sysknife/action-steps",
if spec.action_name == "AddAuthorizedKey" {
"ssh-add"
} else {
"ssh-remove"
},
USERNAME
],
"{} must run as {USERNAME}, not root; got {args:?}",
spec.action_name
);
assert!(
!args.iter().any(|a| a == "-lc" || a.contains("chown")),
"{} must not re-elevate; got {args:?}",
spec.action_name
);
}
}