543 lines
21 KiB
Rust
543 lines
21 KiB
Rust
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
|
|
Forge::parse(url, provider, |destination, _| {
|
|
Ok(destination.rsplit('@').next().unwrap().to_owned())
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
|
|
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] {
|
|
for (closed, merged, expected) in [
|
|
(false, false, "draft"),
|
|
(true, false, "closed"),
|
|
(true, true, "merged"),
|
|
] {
|
|
let mut value = json!({
|
|
"number": 7, "iid": 7, "title": "Unfinished work", "draft": true,
|
|
"state": if merged && provider == Provider::Gitlab { "merged" }
|
|
else if closed { "closed" }
|
|
else if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"merged": merged,
|
|
"merged_at": if merged { Some("2024-01-02T00:00:00Z") } else { None },
|
|
"user": null, "author": null, "created_at": "2024-01-01T00:00:00Z"
|
|
});
|
|
let entry = summary(provider, &value, Some((1, 2))).unwrap();
|
|
assert!(entry.draft);
|
|
assert_eq!(entry.closed, closed);
|
|
assert_eq!(
|
|
entry.description,
|
|
format!("deleted user · {expected} · +1 -2")
|
|
);
|
|
if !closed {
|
|
value["draft"] = json!(false);
|
|
assert!(!summary(provider, &value, None).unwrap().draft);
|
|
value.as_object_mut().unwrap().remove("draft");
|
|
assert!(!summary(provider, &value, None).unwrap().draft);
|
|
value["draft"] = json!("true");
|
|
assert!(summary(provider, &value, None).is_err());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pull_request_sizes_include_zero_and_preserve_unknown_statistics() {
|
|
for (url, provider) in [
|
|
("https://github.com/owner/repo", Provider::Github),
|
|
("https://github.enterprise/owner/repo", Provider::Github),
|
|
("https://gitlab.com/group/repo", Provider::Gitlab),
|
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
|
] {
|
|
let forge = parse(
|
|
url,
|
|
Some(match provider {
|
|
Provider::Github => "github",
|
|
Provider::Gitlab => "gitlab",
|
|
Provider::Gitea => "gitea",
|
|
}),
|
|
)
|
|
.unwrap();
|
|
let page = forge
|
|
.page_with(false, 1, |url, body| {
|
|
if let Some(body) = body {
|
|
let query: Value = serde_json::from_str(body).unwrap();
|
|
let query = query["query"].as_str().unwrap();
|
|
assert!(query.starts_with("query{"));
|
|
assert!(!query.contains("mutation"));
|
|
if provider == Provider::Github {
|
|
assert_eq!(
|
|
url,
|
|
if forge.host == "github.com" {
|
|
"https://api.github.com/graphql"
|
|
} else {
|
|
"https://github.enterprise/api/graphql"
|
|
}
|
|
);
|
|
assert!(query.contains("p7:pullRequest(number:7)"));
|
|
return Ok(json!({"data":{"repository":{
|
|
"p7":{"additions":123,"deletions":45},
|
|
"p8":{"additions":0,"deletions":0},
|
|
"p9":null
|
|
}}}));
|
|
}
|
|
assert_eq!(url, "https://gitlab.com/api/graphql");
|
|
assert!(query.contains("p7:mergeRequest(iid:\"7\")"));
|
|
return Ok(json!({"data":{"project":{
|
|
"p7":{"diffStatsSummary":{"additions":123,"deletions":45}},
|
|
"p8":{"diffStatsSummary":{"additions":0,"deletions":0}},
|
|
"p9":{"diffStatsSummary":null}
|
|
}}}));
|
|
}
|
|
Ok(Value::Array((7..=9).map(|id| json!({
|
|
"merged_at":null, "merged":false,
|
|
"number":id, "iid":id, "title":"Sized PR",
|
|
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"user":{"login":"author"}, "author":{"username":"author"},
|
|
"created_at":"2024-01-01T00:00:00Z",
|
|
"base":{"ref":"main"}, "head":{"ref":"topic"},
|
|
"target_branch":"main", "source_branch":"topic",
|
|
"additions":match id { 7 => Some(123), 8 => Some(0), _ => None },
|
|
"deletions":match id { 7 => Some(45), 8 => Some(0), _ => None }
|
|
})).collect()))
|
|
})
|
|
.unwrap();
|
|
let description = |id| {
|
|
&page
|
|
.entries
|
|
.iter()
|
|
.find(|entry| entry.number == id)
|
|
.unwrap()
|
|
.description
|
|
};
|
|
assert!(description(7).contains("open · +123 -45 · topic → main"));
|
|
assert!(description(8).contains("open · +0 -0 · topic → main"));
|
|
assert!(description(9).contains("size unavailable"));
|
|
assert!(!description(9).contains("+0 -0"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
|
for (provider, url, query) in [
|
|
(
|
|
Provider::Github,
|
|
"https://github.com/owner/repo",
|
|
"state=open&per_page=50&sort=created&direction=desc&page=3",
|
|
),
|
|
(
|
|
Provider::Gitlab,
|
|
"https://gitlab.com/owner/repo",
|
|
"state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3",
|
|
),
|
|
(
|
|
Provider::Gitea,
|
|
"https://codeberg.org/owner/repo",
|
|
"state=open&limit=50&page=3",
|
|
),
|
|
] {
|
|
let forge = parse(url, None).unwrap();
|
|
let page = forge.page_with(false, 3, |url, body| {
|
|
if body.is_some() {
|
|
return Err("statistics unavailable".into());
|
|
}
|
|
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
|
Ok(json!([
|
|
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"},
|
|
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
|
|
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
|
]))
|
|
}).unwrap();
|
|
assert_eq!(
|
|
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
|
[2, 1]
|
|
);
|
|
assert!(page.entries[1].description.contains("deleted user"));
|
|
assert!(
|
|
page.has_more,
|
|
"A short, server-capped page is not exhaustion"
|
|
);
|
|
let empty = forge.page_with(false, 4, |_, _| Ok(json!([]))).unwrap();
|
|
assert!(empty.entries.is_empty() && !empty.has_more);
|
|
assert!(
|
|
forge
|
|
.page_with(false, 0, |_, _| panic!("Page zero must not request"))
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
|
let mut all = Vec::new();
|
|
for page in 1..=3 {
|
|
let mut states = Vec::new();
|
|
let result = forge
|
|
.page_with(true, page, |url, body| {
|
|
if body.is_some() {
|
|
return Err("statistics unavailable".into());
|
|
}
|
|
assert!(url.ends_with(&format!("&page={page}")));
|
|
let state = if url.contains("?state=closed&") {
|
|
"closed"
|
|
} else {
|
|
assert!(url.contains("?state=merged&"));
|
|
"merged"
|
|
};
|
|
states.push(state);
|
|
let (id, date) = match (state, page) {
|
|
("closed", 1) => (1, "2024-01-01T00:00:00Z"),
|
|
("merged", 1) => (3, "2024-03-01T00:00:00Z"),
|
|
("merged", 2) => (2, "2024-02-01T00:00:00Z"),
|
|
_ => return Ok(json!([])),
|
|
};
|
|
Ok(json!([{"iid": id, "title": state, "state": state,
|
|
"author": null, "created_at": date, "diff_refs": null}]))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(states, ["closed", "merged"]);
|
|
assert_eq!(result.has_more, page != 3);
|
|
assert!(result.entries.iter().all(|p| p.closed));
|
|
if page == 1 {
|
|
assert_eq!(
|
|
result.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
|
[3, 1]
|
|
);
|
|
}
|
|
// The second merged page still precedes the first closed page.
|
|
all.extend(result.entries);
|
|
}
|
|
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
|
|
let raced = forge.page_with(true, 1, |url, body| {
|
|
if body.is_some() {
|
|
return Err("statistics unavailable".into());
|
|
}
|
|
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
|
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
|
}).unwrap();
|
|
assert_eq!(raced.entries.len(), 1);
|
|
assert!(raced.entries[0].description.contains("merged"));
|
|
}
|
|
|
|
#[test]
|
|
fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
|
for (input, expected) in [
|
|
(
|
|
"2024-03-01T00:30:12.5+01:00",
|
|
"2024-02-29T23:30:12.500000000Z",
|
|
),
|
|
(
|
|
"2023-12-31T23:30:00-01:00",
|
|
"2024-01-01T00:30:00.000000000Z",
|
|
),
|
|
(
|
|
"2024-01-01T00:00:00.000000001Z",
|
|
"2024-01-01T00:00:00.000000001Z",
|
|
),
|
|
] {
|
|
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected);
|
|
}
|
|
for input in [
|
|
"",
|
|
"not a date",
|
|
"2023-02-29T00:00:00Z",
|
|
"2024-13-01T00:00:00Z",
|
|
"2024-01-01T24:00:00Z",
|
|
"2024-01-01T00:00:00",
|
|
"2024-01-01T00:00:00+24:00",
|
|
"2024-01-01T00:00:00.Z",
|
|
"2024-01-01T00:00:00.1234567890Z",
|
|
"0001-01-01T00:00:00+01:00",
|
|
"9999-12-31T23:00:00-02:00",
|
|
] {
|
|
assert!(
|
|
created_at(&json!({"created_at": input})).is_err(),
|
|
"{input}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_reads_only_selected_detail_and_exact_preserved_head() {
|
|
for (url, provider) in [
|
|
("https://github.com/owner/repo", Provider::Github),
|
|
("https://gitlab.com/group/repo", Provider::Gitlab),
|
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
|
] {
|
|
let forge = parse(url, None).unwrap();
|
|
let mut calls = Vec::new();
|
|
let pr = forge.resolve_with(7, |url| {
|
|
calls.push(url.to_owned());
|
|
if url == format!("{}/7", forge.endpoint) {
|
|
Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" },
|
|
"merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null,
|
|
"base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)},
|
|
"merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic",
|
|
"diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)},
|
|
"url": "https://evil.example/detail", "references": "https://evil.example/refs"}))
|
|
} else {
|
|
assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap()));
|
|
Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}]))
|
|
}
|
|
}).unwrap();
|
|
assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 });
|
|
let revisions = pr.revisions.unwrap();
|
|
assert_eq!(
|
|
revisions.base_sha,
|
|
match provider {
|
|
Provider::Github => "1",
|
|
Provider::Gitlab => "4",
|
|
Provider::Gitea => "3",
|
|
}
|
|
.repeat(40)
|
|
);
|
|
assert_eq!(
|
|
revisions.head_sha,
|
|
match provider {
|
|
Provider::Github => "2",
|
|
Provider::Gitlab => "5",
|
|
Provider::Gitea => "6",
|
|
}
|
|
.repeat(40)
|
|
);
|
|
assert!(
|
|
forge
|
|
.resolve_with(0, |_| panic!("Zero must not request"))
|
|
.is_err()
|
|
);
|
|
let mut calls = 0;
|
|
assert!(
|
|
forge
|
|
.resolve_with(7, |_| {
|
|
calls += 1;
|
|
Ok(json!({"number": 8, "iid": 8}))
|
|
})
|
|
.is_err()
|
|
);
|
|
assert_eq!(
|
|
calls, 1,
|
|
"Reject wrong detail identity before reading historical refs"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() {
|
|
let reference =
|
|
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}});
|
|
assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40));
|
|
assert_eq!(
|
|
gitea_head(&json!([reference.clone()]), 7).unwrap(),
|
|
"a".repeat(40)
|
|
);
|
|
for bad in [
|
|
json!([]),
|
|
json!([reference.clone(), reference.clone()]),
|
|
json!({"ref": "refs/pull/8/head", "object": reference["object"]}),
|
|
json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}),
|
|
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}),
|
|
] {
|
|
assert!(gitea_head(&bad, 7).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() {
|
|
use std::{fs, io::Write, path::PathBuf};
|
|
struct Config(PathBuf);
|
|
impl Drop for Config {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_file(&self.0);
|
|
}
|
|
}
|
|
let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id()));
|
|
let mut file = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&path)
|
|
.unwrap();
|
|
let config = Config(path);
|
|
file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap();
|
|
for (url, expected) in [
|
|
(
|
|
"git@gitea-personal:owner/repo.git",
|
|
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
|
),
|
|
(
|
|
"ssh://git@gitea-personal:2200/owner/repo.git",
|
|
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
|
),
|
|
(
|
|
"git@github-personal:owner/repo.git",
|
|
"https://api.github.com/repos/owner/repo/pulls",
|
|
),
|
|
(
|
|
"git@user-match:group/sub/repo.git",
|
|
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests",
|
|
),
|
|
] {
|
|
let forge = Forge::parse(url, None, |destination, port| {
|
|
let mut command = Command::new("ssh");
|
|
command.arg("-F").arg(&config.0);
|
|
ssh_hostname(command, destination, port, &AtomicBool::new(false))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(forge.endpoint, expected);
|
|
}
|
|
assert!(
|
|
Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok(
|
|
"evil.example/path".into()
|
|
))
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
Forge::parse(
|
|
"ssh://user:secret@alias/owner/repo",
|
|
Some("gitea"),
|
|
|_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") }
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(
|
|
Forge::parse("https://github.com/owner/repo", None, |_, _| panic!(
|
|
"HTTPS must not invoke SSH"
|
|
))
|
|
.unwrap()
|
|
.host,
|
|
"github.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
|
|
let gitlab = parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
|
|
assert_eq!(
|
|
gitlab.endpoint,
|
|
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
|
|
);
|
|
assert_eq!(
|
|
parse("git@github.com:owner/repo.git", None)
|
|
.unwrap()
|
|
.endpoint,
|
|
"https://api.github.com/repos/owner/repo/pulls"
|
|
);
|
|
assert_eq!(
|
|
parse(
|
|
"https://user:secret@forge.example:8443/owner/repo.git",
|
|
Some("gitea")
|
|
)
|
|
.unwrap()
|
|
.endpoint,
|
|
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
|
|
);
|
|
assert!(
|
|
parse("https://forge.example/owner/repo", None)
|
|
.err()
|
|
.unwrap()
|
|
.contains("git config remote.<name>.vamp-forge github")
|
|
);
|
|
for url in [
|
|
"https://github.com/owner/repo?token=secret",
|
|
"https://github.com/owner/%2e%2e",
|
|
"https://github.com/owner/repo%0a",
|
|
"https://github.com:0/owner/repo",
|
|
"file:///owner/repo",
|
|
] {
|
|
assert!(parse(url, Some("github")).is_err(), "{url}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
|
|
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
|
|
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
|
|
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
|
|
for provider in [Provider::Github, Provider::Gitea] {
|
|
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
|
|
let revisions = pr.revisions.as_ref().unwrap();
|
|
assert_eq!(
|
|
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
|
|
("refs/heads/release/next", "refs/pull/7/head")
|
|
);
|
|
assert_eq!(
|
|
revisions.base_sha,
|
|
if provider == Provider::Gitea {
|
|
"4"
|
|
} else {
|
|
"1"
|
|
}
|
|
.repeat(40)
|
|
);
|
|
assert_eq!(
|
|
revisions.head_sha,
|
|
if provider == Provider::Gitea {
|
|
"5"
|
|
} else {
|
|
"2"
|
|
}
|
|
.repeat(40)
|
|
);
|
|
if provider == Provider::Gitea {
|
|
let mut deleted = value.clone();
|
|
deleted["head"]["sha"] = Value::String(String::new());
|
|
assert_eq!(
|
|
pull_request(provider, &deleted, Some("5".repeat(40)))
|
|
.unwrap()
|
|
.revisions
|
|
.unwrap()
|
|
.head_sha,
|
|
"5".repeat(40)
|
|
);
|
|
let unavailable = pull_request(provider, &deleted, None).unwrap();
|
|
assert!(unavailable.revisions.is_err());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
|
|
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
|
|
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
|
|
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
|
|
let revisions = pr.revisions.unwrap();
|
|
assert_eq!(revisions.base_sha, "2".repeat(40));
|
|
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
|
|
value["diff_refs"]["start_sha"] = Value::Null;
|
|
assert_eq!(
|
|
pull_request(Provider::Gitlab, &value, None)
|
|
.unwrap()
|
|
.revisions
|
|
.unwrap()
|
|
.base_sha,
|
|
"1".repeat(40)
|
|
);
|
|
value["target_branch"] = json!("main:refs/heads/injected");
|
|
assert!(
|
|
pull_request(Provider::Gitlab, &value, None)
|
|
.unwrap()
|
|
.revisions
|
|
.is_err()
|
|
);
|
|
value["target_branch"] = json!("main");
|
|
value["diff_refs"]["head_sha"] = json!("HEAD");
|
|
assert!(
|
|
pull_request(Provider::Gitlab, &value, None)
|
|
.unwrap()
|
|
.revisions
|
|
.is_err()
|
|
);
|
|
value["diff_refs"] = Value::Null;
|
|
assert!(
|
|
pull_request(Provider::Gitlab, &value, None)
|
|
.unwrap()
|
|
.revisions
|
|
.is_err()
|
|
);
|
|
}
|