diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 16:44:14 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 16:44:14 +0000 |
| commit | 35199693690345039b2d2db2070bbd652e25328c (patch) | |
| tree | e82465943fcece3d6d24f15f694f85d4df207bfd /grasp-audit/src/specs/grasp01/push_authorization.rs | |
| parent | d2ac69816567f092fe0d4661723bc43778cb481b (diff) | |
test: test_head_set_after_state_event_with_existing_commit
currently failing as branch isn't pushed (we should auto create this branch as we have the ref)
Diffstat (limited to 'grasp-audit/src/specs/grasp01/push_authorization.rs')
| -rw-r--r-- | grasp-audit/src/specs/grasp01/push_authorization.rs | 353 |
1 files changed, 353 insertions, 0 deletions
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 24eae1d..a4731ae 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs | |||
| @@ -390,6 +390,67 @@ fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result<bool, String> | |||
| 390 | Ok(push_output.status.success()) | 390 | Ok(push_output.status.success()) |
| 391 | } | 391 | } |
| 392 | 392 | ||
| 393 | /// Queries the git smart HTTP info/refs endpoint to determine the default branch. | ||
| 394 | /// | ||
| 395 | /// This parses the git-upload-pack service response to find the symref=HEAD capability | ||
| 396 | /// which indicates what branch HEAD points to (i.e., the default branch). | ||
| 397 | /// | ||
| 398 | /// # Arguments | ||
| 399 | /// * `relay_domain` - The relay domain (e.g., "localhost:7000") | ||
| 400 | /// * `npub` - The owner's npub (bech32 public key) | ||
| 401 | /// * `repo_id` - The repository identifier | ||
| 402 | /// | ||
| 403 | /// # Returns | ||
| 404 | /// * `Ok(String)` - The default branch ref (e.g., "refs/heads/main") | ||
| 405 | /// * `Err(String)` - Error message if request or parsing failed | ||
| 406 | async fn get_default_branch_from_info_refs( | ||
| 407 | relay_domain: &str, | ||
| 408 | npub: &str, | ||
| 409 | repo_id: &str, | ||
| 410 | ) -> Result<String, String> { | ||
| 411 | let info_refs_url = format!( | ||
| 412 | "http://{}/{}/{}.git/info/refs?service=git-upload-pack", | ||
| 413 | relay_domain, npub, repo_id | ||
| 414 | ); | ||
| 415 | |||
| 416 | let http_client = reqwest::Client::new(); | ||
| 417 | let response = http_client | ||
| 418 | .get(&info_refs_url) | ||
| 419 | .send() | ||
| 420 | .await | ||
| 421 | .map_err(|e| format!("HTTP request failed: {}", e))?; | ||
| 422 | |||
| 423 | if !response.status().is_success() { | ||
| 424 | return Err(format!( | ||
| 425 | "info/refs returned status {} for URL: {}", | ||
| 426 | response.status(), | ||
| 427 | info_refs_url | ||
| 428 | )); | ||
| 429 | } | ||
| 430 | |||
| 431 | let body = response | ||
| 432 | .text() | ||
| 433 | .await | ||
| 434 | .map_err(|e| format!("Failed to read response body: {}", e))?; | ||
| 435 | |||
| 436 | // Parse the git smart HTTP response to find symref=HEAD:refs/heads/xxx | ||
| 437 | // The format is: capabilities are space-separated after the first NUL byte | ||
| 438 | // Example line: 0000000000000000000000000000000000000000 capabilities^{}\0symref=HEAD:refs/heads/master ... | ||
| 439 | for line in body.lines() { | ||
| 440 | if let Some(caps_start) = line.find('\0') { | ||
| 441 | let caps = &line[caps_start + 1..]; | ||
| 442 | for cap in caps.split(' ') { | ||
| 443 | if cap.starts_with("symref=HEAD:") { | ||
| 444 | let branch = cap.trim_start_matches("symref=HEAD:"); | ||
| 445 | return Ok(branch.to_string()); | ||
| 446 | } | ||
| 447 | } | ||
| 448 | } | ||
| 449 | } | ||
| 450 | |||
| 451 | Err("No symref=HEAD capability found in info/refs response".to_string()) | ||
| 452 | } | ||
| 453 | |||
| 393 | /// Checks if a ref exists on the remote. | 454 | /// Checks if a ref exists on the remote. |
| 394 | #[allow(dead_code)] | 455 | #[allow(dead_code)] |
| 395 | fn ref_exists_on_remote(clone_path: &Path, ref_name: &str) -> Result<bool, String> { | 456 | fn ref_exists_on_remote(clone_path: &Path, ref_name: &str) -> Result<bool, String> { |
| @@ -450,6 +511,9 @@ impl PushAuthorizationTests { | |||
| 450 | ) | 511 | ) |
| 451 | .await, | 512 | .await, |
| 452 | ); | 513 | ); |
| 514 | results.add( | ||
| 515 | Self::test_head_set_after_state_event_with_existing_commit(client, relay_domain).await, | ||
| 516 | ); | ||
| 453 | 517 | ||
| 454 | results | 518 | results |
| 455 | } | 519 | } |
| @@ -1883,6 +1947,295 @@ impl PushAuthorizationTests { | |||
| 1883 | 1947 | ||
| 1884 | TestResult::new(test_name, "GRASP-01", desc).pass() | 1948 | TestResult::new(test_name, "GRASP-01", desc).pass() |
| 1885 | } | 1949 | } |
| 1950 | |||
| 1951 | /// Test that HEAD is set after a state event is published with an existing commit | ||
| 1952 | /// | ||
| 1953 | /// GRASP-01: "MUST set repository HEAD per repository state announcement | ||
| 1954 | /// as soon as the git data related to that branch has been received." | ||
| 1955 | /// | ||
| 1956 | /// This test verifies the HEAD-setting behavior when: | ||
| 1957 | /// 1. A maintainer commit is pushed to the relay (git data exists) | ||
| 1958 | /// 2. A state event is published pointing to that commit with HEAD="refs/heads/develop" | ||
| 1959 | /// 3. The relay should update the repository's default branch to "develop" | ||
| 1960 | /// | ||
| 1961 | /// ## Fixture-First Pattern | ||
| 1962 | /// | ||
| 1963 | /// 1. **Generate**: Create TestContext and get RepoState + MaintainerState fixtures | ||
| 1964 | /// (both commits are pushed as part of the fixture setup) | ||
| 1965 | /// 2. **Send**: Push maintainer commit to relay first, then publish state event with HEAD=develop | ||
| 1966 | /// 3. **Verify**: Query info/refs to verify HEAD symref points to refs/heads/develop | ||
| 1967 | pub async fn test_head_set_after_state_event_with_existing_commit( | ||
| 1968 | client: &AuditClient, | ||
| 1969 | relay_domain: &str, | ||
| 1970 | ) -> TestResult { | ||
| 1971 | use std::process::Command; | ||
| 1972 | |||
| 1973 | let test_name = "test_head_set_after_state_event_with_existing_commit"; | ||
| 1974 | let desc = "HEAD is set when state event published with existing commit"; | ||
| 1975 | |||
| 1976 | // ============================================================ | ||
| 1977 | // Step 1: GENERATE - Create TestContext and get fixtures | ||
| 1978 | // ============================================================ | ||
| 1979 | let ctx = TestContext::new(client); | ||
| 1980 | |||
| 1981 | // Get RepoState fixture (owner's repo announcement + state event) | ||
| 1982 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { | ||
| 1983 | Ok(e) => e, | ||
| 1984 | Err(e) => { | ||
| 1985 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 1986 | .fail(format!("Failed to create RepoState fixture: {}", e)); | ||
| 1987 | } | ||
| 1988 | }; | ||
| 1989 | |||
| 1990 | // Extract repo_id and npub from owner's state event | ||
| 1991 | let repo_id = match state_event | ||
| 1992 | .tags | ||
| 1993 | .iter() | ||
| 1994 | .find(|t| t.kind() == TagKind::d()) | ||
| 1995 | .and_then(|t| t.content()) | ||
| 1996 | { | ||
| 1997 | Some(id) => id.to_string(), | ||
| 1998 | None => { | ||
| 1999 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2000 | .fail("Missing repo_id in state event"); | ||
| 2001 | } | ||
| 2002 | }; | ||
| 2003 | |||
| 2004 | let npub = match state_event.pubkey.to_bech32() { | ||
| 2005 | Ok(n) => n, | ||
| 2006 | Err(e) => { | ||
| 2007 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2008 | .fail(format!("Failed to convert pubkey to bech32: {}", e)); | ||
| 2009 | } | ||
| 2010 | }; | ||
| 2011 | |||
| 2012 | let _maintainer_ann_event = match ctx.get_fixture(FixtureKind::MaintainerAnnouncement).await | ||
| 2013 | { | ||
| 2014 | Ok(e) => e, | ||
| 2015 | Err(e) => { | ||
| 2016 | return TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2017 | "Failed to create MaintainerAnnouncement fixture: {}", | ||
| 2018 | e | ||
| 2019 | )); | ||
| 2020 | } | ||
| 2021 | }; | ||
| 2022 | |||
| 2023 | let _maintainer_state_event = match ctx.get_fixture(FixtureKind::MaintainerState).await { | ||
| 2024 | Ok(e) => e, | ||
| 2025 | Err(e) => { | ||
| 2026 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2027 | .fail(format!("Failed to create MaintainerState fixture: {}", e)); | ||
| 2028 | } | ||
| 2029 | }; | ||
| 2030 | |||
| 2031 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | ||
| 2032 | |||
| 2033 | // ============================================================ | ||
| 2034 | // Step 2: SEND - First push maintainer commit so relay has the git data | ||
| 2035 | // ============================================================ | ||
| 2036 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { | ||
| 2037 | Ok(p) => p, | ||
| 2038 | Err(e) => { | ||
| 2039 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2040 | .fail(format!("Failed to clone repo: {}", e)); | ||
| 2041 | } | ||
| 2042 | }; | ||
| 2043 | |||
| 2044 | let cleanup = || { | ||
| 2045 | let _ = fs::remove_dir_all(&clone_path); | ||
| 2046 | }; | ||
| 2047 | |||
| 2048 | // TODO - this should be pushed inside the MaintainerState fixture. | ||
| 2049 | |||
| 2050 | // Reset to orphan state and create deterministic root commit | ||
| 2051 | // Step 1: Create orphan branch (removes all history) | ||
| 2052 | let _ = Command::new("git") | ||
| 2053 | .args(["checkout", "--orphan", "main"]) | ||
| 2054 | .current_dir(&clone_path) | ||
| 2055 | .output(); | ||
| 2056 | |||
| 2057 | // Step 2: Clear staged files (orphan keeps files staged from previous branch) | ||
| 2058 | let _ = Command::new("git") | ||
| 2059 | .args(["rm", "-rf", "--cached", "."]) | ||
| 2060 | .current_dir(&clone_path) | ||
| 2061 | .output(); | ||
| 2062 | |||
| 2063 | // Step 3: Create deterministic commit using Maintainer variant | ||
| 2064 | let commit_hash = match create_deterministic_commit_with_variant( | ||
| 2065 | &clone_path, | ||
| 2066 | CommitVariant::Maintainer, | ||
| 2067 | ) { | ||
| 2068 | Ok(h) => h, | ||
| 2069 | Err(e) => { | ||
| 2070 | cleanup(); | ||
| 2071 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2072 | .fail(format!("Failed to create maintainer commit: {}", e)); | ||
| 2073 | } | ||
| 2074 | }; | ||
| 2075 | |||
| 2076 | // Verify commit hash matches expected | ||
| 2077 | if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH { | ||
| 2078 | cleanup(); | ||
| 2079 | return TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2080 | "Maintainer commit hash mismatch: got {}, expected {}", | ||
| 2081 | commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH | ||
| 2082 | )); | ||
| 2083 | } | ||
| 2084 | |||
| 2085 | // Push the develop branch with the maintainer commit | ||
| 2086 | let push_output = Command::new("git") | ||
| 2087 | .args(["push", "origin", "main"]) | ||
| 2088 | .current_dir(&clone_path) | ||
| 2089 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 2090 | .output(); | ||
| 2091 | |||
| 2092 | match push_output { | ||
| 2093 | Err(e) => { | ||
| 2094 | cleanup(); | ||
| 2095 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2096 | .fail(format!("Failed to push develop branch: {}", e)); | ||
| 2097 | } | ||
| 2098 | Ok(output) if !output.status.success() => { | ||
| 2099 | // this will fail when not in isolation - as the Recusive state will be the authorised state | ||
| 2100 | // but we need to do it here so the grasp server has the oid | ||
| 2101 | } | ||
| 2102 | _ => {} | ||
| 2103 | } | ||
| 2104 | |||
| 2105 | let _recursive_maintainer_ann_event = match ctx | ||
| 2106 | .get_fixture(FixtureKind::RecursiveMaintainerAnnouncement) | ||
| 2107 | .await | ||
| 2108 | { | ||
| 2109 | Ok(e) => e, | ||
| 2110 | Err(e) => { | ||
| 2111 | return TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2112 | "Failed to create RecursiveMaintainerAnnouncement fixture: {}", | ||
| 2113 | e | ||
| 2114 | )); | ||
| 2115 | } | ||
| 2116 | }; | ||
| 2117 | |||
| 2118 | let _recursive_maintainer_state_event = | ||
| 2119 | match ctx.get_fixture(FixtureKind::RecursiveMaintainerState).await { | ||
| 2120 | Ok(e) => e, | ||
| 2121 | Err(e) => { | ||
| 2122 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2123 | .fail(format!("Failed to create MaintainerState fixture: {}", e)); | ||
| 2124 | } | ||
| 2125 | }; | ||
| 2126 | |||
| 2127 | // Verify commit hash matches expected | ||
| 2128 | if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH { | ||
| 2129 | cleanup(); | ||
| 2130 | return TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2131 | "Maintainer commit hash mismatch: got {}, expected {}", | ||
| 2132 | commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH | ||
| 2133 | )); | ||
| 2134 | } | ||
| 2135 | |||
| 2136 | // Reset to orphan state and create deterministic root commit | ||
| 2137 | // Step 1: Create orphan branch (removes all history) | ||
| 2138 | let _ = Command::new("git") | ||
| 2139 | .args(["checkout", "--orphan", "develop"]) | ||
| 2140 | .current_dir(&clone_path) | ||
| 2141 | .output(); | ||
| 2142 | |||
| 2143 | // Step 2: Clear staged files (orphan keeps files staged from previous branch) | ||
| 2144 | let _ = Command::new("git") | ||
| 2145 | .args(["rm", "-rf", "--cached", "."]) | ||
| 2146 | .current_dir(&clone_path) | ||
| 2147 | .output(); | ||
| 2148 | |||
| 2149 | // ============================================================ | ||
| 2150 | // Step 3: Publish state event with HEAD pointing to develop branch | ||
| 2151 | // ============================================================ | ||
| 2152 | |||
| 2153 | // Create state event with HEAD=refs/heads/develop and develop branch pointing to maintainer commit | ||
| 2154 | let state_event = match client | ||
| 2155 | .event_builder(Kind::Custom(30618), "") | ||
| 2156 | .tag(Tag::identifier(&repo_id)) | ||
| 2157 | .tag(Tag::custom( | ||
| 2158 | TagKind::custom("HEAD"), | ||
| 2159 | vec!["refs/heads/develop".to_string()], | ||
| 2160 | )) | ||
| 2161 | .tag(Tag::custom( | ||
| 2162 | TagKind::custom("refs/heads/develop"), | ||
| 2163 | vec![MAINTAINER_DETERMINISTIC_COMMIT_HASH.to_string()], | ||
| 2164 | )) | ||
| 2165 | .build(client.maintainer_keys()) | ||
| 2166 | { | ||
| 2167 | Ok(e) => e, | ||
| 2168 | Err(e) => { | ||
| 2169 | cleanup(); | ||
| 2170 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2171 | .fail(format!("Failed to build state event: {}", e)); | ||
| 2172 | } | ||
| 2173 | }; | ||
| 2174 | |||
| 2175 | // Send the state event | ||
| 2176 | if let Err(e) = client.client().send_event(&state_event).await { | ||
| 2177 | cleanup(); | ||
| 2178 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2179 | .fail(format!("Failed to send state event: {}", e)); | ||
| 2180 | } | ||
| 2181 | |||
| 2182 | // Wait for relay to process the state event | ||
| 2183 | tokio::time::sleep(std::time::Duration::from_millis(500)).await; | ||
| 2184 | |||
| 2185 | // // Now that state event is published, try pushing again if previous push failed | ||
| 2186 | // let push_output = Command::new("git") | ||
| 2187 | // .args(["push", "-f", "origin", "develop"]) | ||
| 2188 | // .current_dir(&clone_path) | ||
| 2189 | // .env("GIT_TERMINAL_PROMPT", "0") | ||
| 2190 | // .output(); | ||
| 2191 | |||
| 2192 | // match push_output { | ||
| 2193 | // Err(e) => { | ||
| 2194 | // cleanup(); | ||
| 2195 | // return TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2196 | // "Failed to push develop branch after state event: {}", | ||
| 2197 | // e | ||
| 2198 | // )); | ||
| 2199 | // } | ||
| 2200 | // Ok(output) if !output.status.success() => { | ||
| 2201 | // cleanup(); | ||
| 2202 | // return TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2203 | // "Push of develop branch rejected after state event: {}", | ||
| 2204 | // String::from_utf8_lossy(&output.stderr) | ||
| 2205 | // )); | ||
| 2206 | // } | ||
| 2207 | // _ => {} | ||
| 2208 | // } | ||
| 2209 | |||
| 2210 | cleanup(); | ||
| 2211 | |||
| 2212 | // Wait a bit more for HEAD to be updated | ||
| 2213 | tokio::time::sleep(std::time::Duration::from_millis(300)).await; | ||
| 2214 | |||
| 2215 | // ============================================================ | ||
| 2216 | // Step 4: VERIFY - Query info/refs to check the default branch | ||
| 2217 | // ============================================================ | ||
| 2218 | let default_branch = | ||
| 2219 | match get_default_branch_from_info_refs(relay_domain, &npub, &repo_id).await { | ||
| 2220 | Ok(branch) => branch, | ||
| 2221 | Err(e) => { | ||
| 2222 | return TestResult::new(test_name, "GRASP-01", desc) | ||
| 2223 | .fail(format!("Failed to get default branch: {}", e)); | ||
| 2224 | } | ||
| 2225 | }; | ||
| 2226 | |||
| 2227 | // Verify HEAD points to refs/heads/develop | ||
| 2228 | if default_branch == "refs/heads/develop" { | ||
| 2229 | TestResult::new(test_name, "GRASP-01", desc).pass() | ||
| 2230 | } else { | ||
| 2231 | TestResult::new(test_name, "GRASP-01", desc).fail(format!( | ||
| 2232 | "Expected HEAD to point to 'refs/heads/develop' but got '{}'. \ | ||
| 2233 | GRASP-01 requires: 'MUST set repository HEAD per repository state announcement \ | ||
| 2234 | as soon as the git data related to that branch has been received.'", | ||
| 2235 | default_branch | ||
| 2236 | )) | ||
| 2237 | } | ||
| 2238 | } | ||
| 1886 | } | 2239 | } |
| 1887 | 2240 | ||
| 1888 | #[cfg(test)] | 2241 | #[cfg(test)] |