use super::{Positional, classify, looks_like_repo, path_segments, resolve_register_args};
#[test]
fn classify_sorts_every_shape() {
for s in [
"https://github.com/owner/repo",
"git@github.com:owner/repo.git",
"ssh://git@git.example.com:2222/team/thing.git",
"github.com/owner/repo",
"localhost:3000/owner/repo",
"/srv/git/repo.git",
"~/src/repo.git",
] {
assert!(matches!(classify(s), Positional::Url(_)), "{s} not a URL");
}
assert_eq!(
classify("bobmatnyc/trusty-tools"),
Positional::Shorthand {
account: None,
owner: "bobmatnyc",
repo: "trusty-tools"
}
);
assert_eq!(
classify("123/repo"),
Positional::Shorthand {
account: None,
owner: "123",
repo: "repo"
}
);
assert_eq!(
classify("owner/repo.js"),
Positional::Shorthand {
account: None,
owner: "owner",
repo: "repo.js"
}
);
for s in [
"./repo",
"../repo",
".",
"..",
".hidden/repo",
".config/x/y",
] {
assert_eq!(classify(s), Positional::RelativePath, "{s} misclassified");
}
for s in ["my-alias", "owner/", "a/b/c", "owner/re po", "owner:repo"] {
assert_eq!(classify(s), Positional::Other, "{s} misclassified");
}
}
#[test]
fn looks_like_repo_rejects_aliases() {
for alias in ["my-alias", "proj", "a.b-c", "repo123", "owner-repo"] {
assert!(!looks_like_repo(alias), "{alias} misread as a repo");
}
}
#[test]
fn shorthand_resolves_to_github() {
let (alias, url) = resolve_register_args("bobmatnyc/trusty-tools", None).unwrap();
assert_eq!(url, "https://github.com/bobmatnyc/trusty-tools");
assert_eq!(alias, "bobmatnyc-trusty-tools");
let (from_url, _) =
resolve_register_args("https://github.com/bobmatnyc/trusty-tools", None).unwrap();
assert_eq!(alias, from_url);
}
#[test]
fn shorthand_numeric_owner() {
let (alias, url) = resolve_register_args("123/repo", None).unwrap();
assert_eq!(url, "https://github.com/123/repo");
assert_eq!(alias, "123-repo");
}
#[test]
fn shorthand_with_explicit_alias() {
let (alias, url) = resolve_register_args("owner/repo", Some("shiny")).unwrap();
assert_eq!(alias, "shiny");
assert_eq!(url, "https://github.com/owner/repo");
let (alias, url) = resolve_register_args("shiny", Some("owner/repo")).unwrap();
assert_eq!(alias, "shiny");
assert_eq!(url, "https://github.com/owner/repo");
}
#[test]
fn relative_paths_are_never_shorthand() {
for s in [
"./repo",
"../repo",
"./owner/repo",
"../a/b",
".hidden/repo",
".github/workflows",
] {
let err = resolve_register_args(s, None).unwrap_err().to_string();
assert!(
err.contains("relative path"),
"{s} must be refused as a path, got: {err}"
);
}
}
#[test]
fn absolute_and_home_paths_are_accepted() {
let (alias, url) = resolve_register_args("/srv/git/repo.git", Some("local")).unwrap();
assert_eq!(alias, "local");
assert_eq!(url, "/srv/git/repo.git", "an absolute path is stored as-is");
assert!(resolve_register_args("~/src/repo.git", Some("home")).is_ok());
}
#[test]
fn home_relative_path_is_expanded() {
let home = dirs::home_dir().expect("home directory");
let (_, url) = resolve_register_args("~/src/repo.git", Some("home")).unwrap();
assert_eq!(url, home.join("src/repo.git").to_string_lossy());
assert!(!url.contains('~'), "tilde must not survive: {url}");
}
#[test]
fn two_args_url_first() {
let (alias, url) =
resolve_register_args("https://github.com/owner/repo", Some("my-alias")).unwrap();
assert_eq!(alias, "my-alias");
assert_eq!(url, "https://github.com/owner/repo");
}
#[test]
fn two_args_legacy_alias_first() {
let (alias, url) =
resolve_register_args("my-alias", Some("https://github.com/owner/repo")).unwrap();
assert_eq!(alias, "my-alias");
assert_eq!(url, "https://github.com/owner/repo");
}
#[test]
fn two_args_legacy_ssh_url_second() {
let (alias, url) =
resolve_register_args("proj", Some("git@github.com:owner/repo.git")).unwrap();
assert_eq!(alias, "proj");
assert_eq!(url, "git@github.com:owner/repo.git");
}
#[test]
fn one_arg_derives() {
let (alias, url) = resolve_register_args("https://github.com/owner/repo", None).unwrap();
assert_eq!(alias, "owner-repo");
assert_eq!(url, "https://github.com/owner/repo");
}
#[test]
fn one_arg_non_repo_errors() {
for s in ["my-alias", "owner/", "a/b/c"] {
let err = resolve_register_args(s, None).unwrap_err().to_string();
assert!(
err.contains("does not name a repository"),
"{s}: unexpected {err}"
);
assert!(err.contains("<owner>/<repo>"), "no remedy in: {err}");
}
}
#[test]
fn two_urls_error() {
for (a, b) in [
("https://github.com/a/b", "https://github.com/c/d"),
("owner/repo", "other/repo"),
] {
let err = resolve_register_args(a, Some(b)).unwrap_err().to_string();
assert!(
err.contains("both arguments name a repository"),
"({a}, {b}): unexpected {err}"
);
}
}
#[test]
fn two_non_urls_error() {
let err = resolve_register_args("alpha", Some("beta"))
.unwrap_err()
.to_string();
assert!(
err.contains("neither argument names a repository"),
"unexpected: {err}"
);
}
#[test]
fn derives_from_every_url_shape() {
let cases = [
("https://github.com/owner/repo", "owner-repo"),
("https://github.com/owner/repo.git", "owner-repo"),
("https://github.com/owner/repo/", "owner-repo"),
("https://github.com/owner/repo.git/", "owner-repo"),
("git@github.com:owner/repo.git", "owner-repo"),
("git@github.com:owner/repo", "owner-repo"),
("https://gitlab.com/acme/widget.git", "acme-widget"),
("https://git.example.com/team/thing", "team-thing"),
(
"ssh://git@git.example.com:2222/team/thing.git",
"team-thing",
),
("git@bitbucket.org:team/thing.git", "team-thing"),
];
for (url, want) in cases {
let (alias, got_url) = resolve_register_args(url, None).unwrap();
assert_eq!(alias, want, "wrong alias for {url}");
assert_eq!(got_url, url, "URL must be stored verbatim");
}
}
#[test]
fn scheme_less_host_gains_https() {
let cases = [
(
"github.com/owner/repo",
"https://github.com/owner/repo",
"owner-repo",
),
("a.b/c", "https://a.b/c", "c"),
("localhost/repo", "https://localhost/repo", "repo"),
];
for (input, want_url, want_alias) in cases {
let (alias, url) = resolve_register_args(input, None).unwrap();
assert_eq!(url, want_url, "for {input}");
assert_eq!(alias, want_alias, "for {input}");
}
}
#[test]
fn no_owner_falls_back_to_repo() {
let (alias, _) = resolve_register_args("https://example.com/repo", None).unwrap();
assert_eq!(alias, "repo");
}
#[test]
fn host_only_url_errors() {
for url in [
"https://example.com/",
"https://example.com",
"https://example.com:8080",
"git@github.com:",
] {
let err = resolve_register_args(url, None).unwrap_err().to_string();
assert!(
err.contains("no owner/repo path"),
"{url} must be refused, got: {err}"
);
}
}
#[test]
fn browser_paste_shapes_are_rejected() {
let cases = [
"https://github.com/owner/repo/tree/main",
"https://github.com/owner/repo/pull/4914",
"https://github.com/owner/repo/blob/main/README.md",
"https://github.com/owner/repo/issues/12",
"https://github.com/owner/repo/commit/abc123",
"https://gitlab.com/group/sub/repo/-/blob/main/x",
];
for url in cases {
let err = resolve_register_args(url, None).unwrap_err().to_string();
assert!(
err.contains("points inside a repository"),
"{url} must be refused, got: {err}"
);
assert!(err.contains("repository root"), "no remedy in: {err}");
}
}
#[test]
fn github_tab_urls_are_rejected() {
let cases = [
"https://github.com/owner/repo/issues",
"https://github.com/owner/repo/pulls",
"https://github.com/owner/repo/releases",
"https://github.com/owner/repo/wiki",
"https://github.com/owner/repo/actions",
"https://github.com/owner/repo/settings",
"github.com/owner/repo/issues",
"git@github.com:owner/repo/issues",
];
for url in cases {
let err = resolve_register_args(url, None).unwrap_err().to_string();
assert!(
err.contains("points inside a repository"),
"{url} must be refused, got: {err}"
);
assert!(err.contains("repository root"), "no remedy in: {err}");
}
}
#[test]
fn repo_named_like_a_web_path_is_ok() {
let (alias, _) = resolve_register_args("https://github.com/owner/tree", None).unwrap();
assert_eq!(alias, "owner-tree");
let (alias, _) = resolve_register_args("https://gitlab.com/group/sub/blob", None).unwrap();
assert_eq!(alias, "sub-blob");
}
#[test]
fn local_paths_skip_the_web_path_rule() {
for input in [
"/Users/me/actions/repo.git",
"/home/bob/raw/myrepo",
"/srv/git/tree/repo.git",
"file:///Users/me/actions/repo.git",
] {
resolve_register_args(input, Some("local"))
.unwrap_or_else(|e| panic!("{input} must register, got: {e}"));
}
let (_, url) = resolve_register_args("~/tree/repo.git", Some("home")).unwrap();
assert!(url.ends_with("/tree/repo.git"), "unexpected: {url}");
assert!(!url.contains('~'), "tilde must not survive: {url}");
}
#[test]
fn query_and_fragment_are_stripped() {
for input in [
"https://github.com/owner/repo?tab=readme-ov-file",
"https://github.com/owner/repo#readme",
] {
let (alias, url) = resolve_register_args(input, None).unwrap();
assert_eq!(alias, "owner-repo", "for {input}");
assert_eq!(url, "https://github.com/owner/repo", "for {input}");
}
}
#[test]
fn hyphen_flattening_ambiguity_is_accepted_and_collides() {
let (a, _) = resolve_register_args("a/b-c", None).unwrap();
let (b, _) = resolve_register_args("a-b/c", None).unwrap();
assert_eq!(a, "a-b-c");
assert_eq!(b, "a-b-c");
}
#[test]
fn path_segments_matches_every_url_shape() {
let cases: [(&str, Vec<&str>); 8] = [
("https://github.com/owner/repo", vec!["owner", "repo"]),
("https://github.com/owner/repo/", vec!["owner", "repo"]),
("github.com/owner/repo", vec!["owner", "repo"]),
("git@github.com:owner/repo.git", vec!["owner", "repo.git"]),
(
"ssh://git@git.example.com:2222/team/thing.git",
vec!["team", "thing.git"],
),
("https://example.com:8080/o/r", vec!["o", "r"]),
("https://example.com/repo", vec!["repo"]),
(
"https://github.com/o/r/tree/main",
vec!["o", "r", "tree", "main"],
),
];
for (url, want) in cases {
assert_eq!(path_segments(url), want, "wrong segments for {url}");
}
}
#[test]
fn path_segments_keeps_numeric_owner() {
assert_eq!(
path_segments("https://github.com/123/repo"),
["123", "repo"]
);
let (alias, _) = resolve_register_args("https://github.com/123/repo", None).unwrap();
assert_eq!(alias, "123-repo");
}
#[test]
fn path_segments_host_only() {
for url in ["https://example.com", "https://example.com/", "example.com"] {
assert!(
path_segments(url).is_empty(),
"{url} must yield no path segments"
);
}
}
#[test]
fn collision_refuses_and_leaves_registry_untouched() {
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
crate::commands::standalone::register_cmd(&paths, "a/b-c", None, false).unwrap();
let registry_path = dir.path().join("registry.json");
let before = std::fs::read(®istry_path).unwrap();
let err = crate::commands::standalone::register_cmd(&paths, "a-b/c", None, false).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("already registered"),
"collision must be loud: {msg}"
);
let after = std::fs::read(®istry_path).unwrap();
assert_eq!(
before, after,
"a refused registration must not mutate the registry"
);
}
#[test]
fn rejected_url_writes_nothing() {
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
for bad in ["./repo", "https://example.com", "a/b/c"] {
assert!(
crate::commands::standalone::register_cmd(&paths, bad, None, false).is_err(),
"{bad} must be refused"
);
}
assert!(
!dir.path().join("registry.json").exists(),
"a refused argument must not write a registry"
);
}
#[test]
fn shorthand_registers_end_to_end() {
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
crate::commands::standalone::register_cmd(&paths, "bobmatnyc/trusty-tools", None, false)
.unwrap();
let registry =
trusty_mpm::core::standalone::registry::ManagedRegistry::load(dir.path()).unwrap();
let entries = registry.list();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "bobmatnyc-trusty-tools");
assert_eq!(entries[0].url, "https://github.com/bobmatnyc/trusty-tools");
}
#[test]
fn same_url_reregistration_is_idempotent() {
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
crate::commands::standalone::register_cmd(&paths, "owner/repo", None, false).unwrap();
crate::commands::standalone::register_cmd(&paths, "https://github.com/owner/repo", None, false)
.unwrap();
let registry =
trusty_mpm::core::standalone::registry::ManagedRegistry::load(dir.path()).unwrap();
let entries = registry.list();
assert_eq!(entries.len(), 1, "shorthand and full URL are one repo");
assert_eq!(entries[0].alias, "owner-repo");
}
#[test]
fn account_shorthand_classifies_and_looks_like_repo() {
assert_eq!(
classify("bob-duetto@duettoresearch/poc-hotel-supply"),
Positional::Shorthand {
account: Some("bob-duetto"),
owner: "duettoresearch",
repo: "poc-hotel-supply",
}
);
assert!(looks_like_repo(
"bob-duetto@duettoresearch/poc-hotel-supply"
));
assert_eq!(
super::embedded_account("bob-duetto@duettoresearch/poc-hotel-supply"),
Some("bob-duetto")
);
}
#[test]
fn ssh_url_is_not_mistaken_for_account_shorthand() {
assert_eq!(
classify("git@github.com:owner/repo.git"),
Positional::Url("git@github.com:owner/repo.git")
);
assert_eq!(
super::embedded_account("git@github.com:owner/repo.git"),
None
);
}
#[test]
fn account_prefix_before_a_full_url_is_not_shorthand() {
assert_eq!(
classify("bob@https://github.com/owner/repo"),
Positional::Url("bob@https://github.com/owner/repo")
);
assert_eq!(
super::embedded_account("bob@https://github.com/owner/repo"),
None
);
}
#[test]
fn account_shorthand_resolves_to_the_same_url_as_plain_shorthand() {
assert_eq!(
super::resolved_url("bob-duetto@duettoresearch/poc-hotel-supply").unwrap(),
"https://github.com/duettoresearch/poc-hotel-supply"
);
}
#[test]
fn embedded_account_none_for_plain_shorthand_and_urls() {
assert_eq!(super::embedded_account("owner/repo"), None);
assert_eq!(
super::embedded_account("https://github.com/owner/repo"),
None
);
}
#[test]
fn resolve_account_flag_only() {
assert_eq!(
super::resolve_account(Some("bobmatnyc"), None).unwrap(),
Some("bobmatnyc".to_string())
);
}
#[test]
fn resolve_account_embedded_only() {
assert_eq!(
super::resolve_account(None, Some("bobmatnyc")).unwrap(),
Some("bobmatnyc".to_string())
);
}
#[test]
fn resolve_account_agreeing_flag_and_embedded() {
assert_eq!(
super::resolve_account(Some("bobmatnyc"), Some("bobmatnyc")).unwrap(),
Some("bobmatnyc".to_string())
);
}
#[test]
fn resolve_account_conflicting_flag_and_embedded_errors() {
let err = super::resolve_account(Some("bobmatnyc"), Some("bob-duetto")).unwrap_err();
assert!(
err.to_string().contains("conflicting account selection"),
"{err}"
);
}
#[test]
fn resolve_account_neither_is_none() {
assert_eq!(super::resolve_account(None, None).unwrap(), None);
}
#[test]
fn resolve_account_blank_flag_is_treated_as_absent() {
assert_eq!(
super::resolve_account(Some(" "), Some("bobmatnyc")).unwrap(),
Some("bobmatnyc".to_string())
);
}
#[test]
fn resolve_account_rejects_a_malformed_flag_value() {
let err = super::resolve_account(Some("not a login"), None).unwrap_err();
assert!(err.to_string().contains("not a login"), "{err}");
let err = super::resolve_account(Some("bob;rm -rf"), None).unwrap_err();
assert!(
err.to_string().contains("not a plausible gh login"),
"{err}"
);
}
const TOKEN_URL_PARTS: [&str; 2] = ["secretqatoken2", "qauser"];
fn assert_no_token(what: &str, text: &str) {
let lowered = text.to_ascii_lowercase();
for part in TOKEN_URL_PARTS {
assert!(!lowered.contains(part), "{what} leaks {part:?}: {text}");
}
}
#[test]
fn register_never_prints_or_stores_an_embedded_token_9124() {
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
let line = crate::commands::standalone::register_alias(
&paths,
"https://qauser:SECRETQATOKEN2@example.invalid/org/x.git",
None,
false,
)
.unwrap();
assert_no_token("the printed line", &line);
let stored = std::fs::read_to_string(dir.path().join("registry.json")).unwrap();
assert_no_token("registry.json", &stored);
let registry =
trusty_mpm::core::standalone::registry::ManagedRegistry::load(dir.path()).unwrap();
assert_eq!(registry.list()[0].url, "https://example.invalid/org/x.git");
assert_eq!(registry.list()[0].alias, "org-x");
}
#[test]
fn register_errors_never_echo_an_embedded_token_9124() {
for bad in [
"https://qauser:SECRETQATOKEN2@example.invalid/",
"https://qauser:SECRETQATOKEN2@github.com/o/r/issues",
"https://qauser:pa'ssSECRETQATOKEN2@example.invalid/",
"https://qauser:\"pSECRETQATOKEN2\"@example.invalid/",
"https://qauser:pa ssSECRETQATOKEN2@github.com/o/r/issues",
] {
let err = resolve_register_args(bad, None).unwrap_err();
assert_no_token(bad, &format!("{err:#}"));
}
let err = resolve_register_args(
"https://qauser:SECRETQATOKEN2@example.invalid/a/b",
Some("https://qauser:SECRETQATOKEN2@example.invalid/c/d"),
)
.unwrap_err();
assert_no_token("two repos", &format!("{err:#}"));
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
let mut old =
trusty_mpm::core::standalone::registry::ManagedRegistry::load(dir.path()).unwrap();
old.add(
"org-x",
"https://qauser:SECRETQATOKEN2@example.invalid/org/x.git",
false,
)
.unwrap();
old.save().unwrap();
let err = crate::commands::standalone::register_alias(
&paths,
"https://example.invalid/other/y.git",
Some("org-x"),
false,
)
.unwrap_err();
assert_no_token("the collision", &format!("{err:#}"));
}
fn stored_after_register(url: &str) -> String {
let dir = tempfile::TempDir::new().unwrap();
let paths = crate::commands::managed_root::ManagedPaths::from_root(dir.path().to_path_buf());
let line = crate::commands::standalone::register_alias(&paths, url, None, false).unwrap();
crate::commands::standalone::register_alias(&paths, url, None, false).unwrap();
let registry =
trusty_mpm::core::standalone::registry::ManagedRegistry::load(dir.path()).unwrap();
let stored = registry.list()[0].url.clone();
assert!(line.ends_with(&stored), "{line:?} names {stored:?}");
stored
}
#[test]
fn register_keeps_the_ssh_login_and_drops_only_the_secret_9155() {
for (url, want, removed) in [
("git@github.com:o/r.git", "git@github.com:o/r.git", false),
("ssh://git@h:2222/t/r", "ssh://git@h:2222/t/r", false),
(
"ssh://u:SECRETQATOKEN2@h:2222/t/r",
"ssh://u@h:2222/t/r",
true,
),
(
"https://SECRETQATOKEN2@github.com/o/r",
"https://github.com/o/r",
true,
),
(
"https://qauser:SECRETQATOKEN2@github.com/o/r",
"https://github.com/o/r",
true,
),
] {
assert_eq!(stored_after_register(url), want, "{url:?}");
let (form, notice) = crate::commands::standalone::storage_form(url);
assert_eq!(form, want, "{url:?}");
assert_eq!(notice.is_some(), removed, "{url:?}");
assert_no_token(url, notice.unwrap_or_default());
}
let scp = "qauser:SECRETQATOKEN2@host:o/r";
assert_eq!(
crate::commands::standalone::storage_form(scp),
(
"qauser@host:o/r".to_string(),
Some(crate::commands::standalone::SECRET_REMOVED_NOTICE)
)
);
let err = resolve_register_args(scp, None).unwrap_err();
assert_no_token(scp, &format!("{err:#}"));
}
#[test]
fn shown_masks_an_scp_style_password_9124() {
assert_eq!(
super::shown("qauser:SECRETQATOKEN2@host:o/r"),
"***@host:o/r"
);
assert_eq!(
super::shown("git@github.com:o/r.git"),
"git@github.com:o/r.git"
);
assert_eq!(
super::shown("https://qauser:SECRETQATOKEN2@h/o/r"),
"https://***@h/o/r"
);
for (s, want) in [
(
"https://qauser:pa'ssSECRETQATOKEN2@h/o/r",
"https://***@h/o/r",
),
(
"https://qauser:\"p@ssSECRETQATOKEN2\"@h/o/r",
"https://***@h/o/r",
),
("qauser:pa ssSECRETQATOKEN2@host:o/r", "***@host:o/r"),
("qauser:pa'ssSECRETQATOKEN2@h:o/r://x", "***@h:o/r://x"),
] {
assert_eq!(super::shown(s), want);
}
}