openpencil/crates/op-git/src/tests_auth.rs

204 lines
6.3 KiB
Rust

//! Credential + SSH-key store tests: key generate / list / load /
//! delete / import, name-validation hardening, and the JSON-backed
//! `AuthStore` (set / get / replace / remove / persistence / perms).
use crate::tests::{ssh_keygen_available, unique_temp_dir};
use crate::{AuthStore, Credential, SshKeyStore};
#[test]
fn ssh_key_generate_list_load_and_delete() {
if !ssh_keygen_available() {
return;
}
let dir = unique_temp_dir("ssh");
let store = SshKeyStore::at(&dir);
assert!(store.list().expect("list empty").is_empty());
let key = store.generate("id_test", "op@test").expect("generate");
assert_eq!(key.name, "id_test");
assert!(key.public_key.starts_with("ssh-ed25519 "));
assert!(key.public_key.contains("op@test"));
assert!(key.private_path.is_file());
let listed = store.list().expect("list");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0], key);
// Re-generating an existing key name is refused (no overwrite).
assert!(store.generate("id_test", "x").is_err());
store.delete("id_test").expect("delete");
assert!(store.list().expect("list after delete").is_empty());
// Deleting a missing key is a tolerated no-op.
store.delete("id_test").expect("delete is idempotent");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auth_store_set_get_replace_remove_and_persist() {
let dir = unique_temp_dir("auth");
let path = dir.join("git-auth.json");
let store = AuthStore::at(&path);
// Empty store.
assert!(store.hosts().expect("hosts").is_empty());
assert!(store.get("github.com").expect("get").is_none());
// Store an HTTPS credential.
let https = Credential::Https {
username: "octocat".to_string(),
token: "ghp_secret".to_string(),
};
store.set("github.com", https.clone()).expect("set https");
assert_eq!(store.get("github.com").expect("get"), Some(https));
// A second host with an SSH credential; both must survive a
// reload from a fresh handle on the same file.
store
.set(
"git.example.com",
Credential::Ssh {
key_name: "id_work".to_string(),
},
)
.expect("set ssh");
let reloaded = AuthStore::at(&path);
assert_eq!(
reloaded.hosts().expect("hosts"),
vec!["git.example.com".to_string(), "github.com".to_string()]
);
// Re-setting a host replaces its credential.
store
.set(
"github.com",
Credential::Ssh {
key_name: "id_personal".to_string(),
},
)
.expect("replace");
assert_eq!(
store.get("github.com").expect("get"),
Some(Credential::Ssh {
key_name: "id_personal".to_string()
})
);
// Remove — and removing an absent host is a tolerated no-op.
store.remove("github.com").expect("remove");
assert!(store.get("github.com").expect("get").is_none());
store.remove("github.com").expect("remove is idempotent");
assert_eq!(
store.hosts().expect("hosts"),
vec!["git.example.com".to_string()]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn auth_store_file_is_created_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_temp_dir("auth-perms");
let path = dir.join("git-auth.json");
let store = AuthStore::at(&path);
store
.set(
"host",
Credential::Ssh {
key_name: "k".to_string(),
},
)
.expect("set");
// The store holds secrets — it must never be world-readable,
// not even momentarily during the write.
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "credential store must be owner-only (0600)");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn auth_store_at_accepts_non_utf8_file_names() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = unique_temp_dir("auth-non-utf8");
let path = dir.join(OsString::from_vec(b"git-auth-\xff.json".to_vec()));
let store = AuthStore::at(&path);
store
.set(
"host",
Credential::Ssh {
key_name: "id_non_utf8".to_string(),
},
)
.expect("set non-utf8 path");
let reloaded = AuthStore::at(&path);
assert_eq!(
reloaded.get("host").expect("get non-utf8 path"),
Some(Credential::Ssh {
key_name: "id_non_utf8".to_string()
})
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ssh_key_store_rejects_directory_escaping_names() {
// No `ssh-keygen` needed: name validation runs first, before any
// filesystem work or subprocess.
let store = SshKeyStore::at(unique_temp_dir("ssh-reject"));
for bad in ["..", ".", "", "../escape", "sub/key", "/abs/key", "a\\b"] {
assert!(
store.generate(bad, "c").is_err(),
"generate must reject `{bad}`"
);
assert!(store.load(bad).is_err(), "load must reject `{bad}`");
assert!(store.delete(bad).is_err(), "delete must reject `{bad}`");
assert!(
store
.import(std::path::Path::new("/nonexistent"), bad)
.is_err(),
"import must reject `{bad}`"
);
}
}
#[test]
fn ssh_key_import_copies_the_pair() {
if !ssh_keygen_available() {
return;
}
// Generate a key in a "source" store, then import it into a
// separate store under a new name.
let src_dir = unique_temp_dir("ssh-src");
let src = SshKeyStore::at(&src_dir);
let original = src.generate("orig", "src@test").expect("generate");
let dst_dir = unique_temp_dir("ssh-dst");
let dst = SshKeyStore::at(&dst_dir);
let imported = dst
.import(&original.private_path, "imported")
.expect("import");
assert_eq!(imported.name, "imported");
// The public key is carried over verbatim.
assert_eq!(imported.public_key, original.public_key);
assert!(dst_dir.join("imported").is_file());
assert!(dst_dir.join("imported.pub").is_file());
for dir in [src_dir, dst_dir] {
let _ = std::fs::remove_dir_all(dir);
}
}