use super::*; use serde_json::json; fn parse(url: &str, provider: Option<&str>) -> Result { Forge::parse(url, provider, |destination, _| { Ok(destination.rsplit('@').next().unwrap().to_owned()) }) } #[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::>(), [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::>(), [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::>(), [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..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() ); }