diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 20:46:05 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 20:46:05 +0000 |
| commit | b95460cb136f3e022896148afb9c67755af5d832 (patch) | |
| tree | bbe64da1ba3ee502a18f3856838202d4976e4da7 /grasp-audit/src/fixtures.rs | |
| parent | a21a795b20f89eafde52b1fecc01ba6ddb1bad56 (diff) | |
begin implementing better fixtures
Diffstat (limited to 'grasp-audit/src/fixtures.rs')
| -rw-r--r-- | grasp-audit/src/fixtures.rs | 217 |
1 files changed, 217 insertions, 0 deletions
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 4b5014d..062bb9b 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs | |||
| @@ -17,6 +17,27 @@ | |||
| 17 | //! This eliminates the need for global state while still enabling fixture reuse | 17 | //! This eliminates the need for global state while still enabling fixture reuse |
| 18 | //! when appropriate. | 18 | //! when appropriate. |
| 19 | //! | 19 | //! |
| 20 | //! # What is a Fixture? | ||
| 21 | //! A fixture represents the state of a repository on a grasp server and/or nostr events to be | ||
| 22 | //! sent to the server to change this state. | ||
| 23 | //! | ||
| 24 | //! 1. <event-name>Generated - Nostr Event created (not yet sent) | ||
| 25 | //! 2. <event-name>Sent - Sent To Grasp Server | ||
| 26 | //! 3. <event-name> - Verfied and Confirmed as accepted via client query | ||
| 27 | //! 4. <event-or-data-pushed-name>DataPushed - what refs were pushed | ||
| 28 | //! | ||
| 29 | //! Some Nostr Events need each of these stages as seperate fixtures whereas 1-3 or event 1-4 are often | ||
| 30 | //! bundled and 4 is only sometimes needed. | ||
| 31 | //! | ||
| 32 | //! Nearly all fixures include dependant fixtures so tests dont need to call every parent fixture. | ||
| 33 | //! | ||
| 34 | //! As entire tests are often fixtures to be built on by other fixtures / tests, some tests just take | ||
| 35 | //! the fixture Result and wrap it in pass fail using the error message. | ||
| 36 | //! | ||
| 37 | //! # Out of Scope | ||
| 38 | //! | ||
| 39 | //! local repo's used in tests are always cloned fresh and never part of a fixture | ||
| 40 | //! | ||
| 20 | //! # Example | 41 | //! # Example |
| 21 | //! | 42 | //! |
| 22 | //! ```no_run | 43 | //! ```no_run |
| @@ -166,6 +187,20 @@ pub enum FixtureKind { | |||
| 166 | /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH | 187 | /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH |
| 167 | /// - Timestamp: 1 second in the past | 188 | /// - Timestamp: 1 second in the past |
| 168 | PREvent, | 189 | PREvent, |
| 190 | |||
| 191 | /// Owner's state event with git data successfully pushed (full 4-stage fixture) | ||
| 192 | /// | ||
| 193 | /// This fixture represents the complete flow for testing push authorization: | ||
| 194 | /// 1. **Generated**: Creates RepoState (repo announcement + state event) | ||
| 195 | /// 2. **Sent**: Sends events to relay | ||
| 196 | /// 3. **Verified**: Confirms events accepted by relay | ||
| 197 | /// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay | ||
| 198 | /// | ||
| 199 | /// - Requires ValidRepo (uses same repo_id) | ||
| 200 | /// - State event signed by owner keys (`client.keys()`) | ||
| 201 | /// - Points to DETERMINISTIC_COMMIT_HASH | ||
| 202 | /// - Git push verified to succeed (state matches pushed commit) | ||
| 203 | OwnerStateDataPushed, | ||
| 169 | } | 204 | } |
| 170 | 205 | ||
| 171 | /// Context mode for fixture management | 206 | /// Context mode for fixture management |
| @@ -742,6 +777,10 @@ impl<'a> TestContext<'a> { | |||
| 742 | .build(self.client.pr_author_keys()) | 777 | .build(self.client.pr_author_keys()) |
| 743 | .map_err(|e| anyhow::anyhow!("Failed to build PR event: {}", e)) | 778 | .map_err(|e| anyhow::anyhow!("Failed to build PR event: {}", e)) |
| 744 | } | 779 | } |
| 780 | |||
| 781 | FixtureKind::OwnerStateDataPushed => { | ||
| 782 | self.build_owner_state_data_pushed().await | ||
| 783 | } | ||
| 745 | } | 784 | } |
| 746 | } | 785 | } |
| 747 | 786 | ||
| @@ -907,6 +946,184 @@ impl<'a> TestContext<'a> { | |||
| 907 | }) | 946 | }) |
| 908 | } | 947 | } |
| 909 | 948 | ||
| 949 | /// Build OwnerStateDataPushed fixture: full 4-stage fixture for push authorization | ||
| 950 | /// | ||
| 951 | /// This handles all stages of the fixture: | ||
| 952 | /// 1. **Generated**: Creates RepoState (repo announcement + state event) | ||
| 953 | /// 2. **Sent**: Sends events to relay | ||
| 954 | /// 3. **Verified**: Confirms events accepted by relay | ||
| 955 | /// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay | ||
| 956 | /// | ||
| 957 | /// # Returns | ||
| 958 | /// The state event (kind 30618) after all stages complete successfully | ||
| 959 | async fn build_owner_state_data_pushed(&self) -> Result<Event> { | ||
| 960 | use nostr_sdk::prelude::*; | ||
| 961 | |||
| 962 | // ============================================================ | ||
| 963 | // Stage 1 & 2: Generate and Send RepoState fixture | ||
| 964 | // (get_or_create_repo handles caching, build_fixture builds state event) | ||
| 965 | // ============================================================ | ||
| 966 | let repo = self.get_or_create_repo().await?; | ||
| 967 | |||
| 968 | // Extract repo_id from repo announcement | ||
| 969 | let repo_id = repo | ||
| 970 | .tags | ||
| 971 | .iter() | ||
| 972 | .find(|t| t.kind() == TagKind::d()) | ||
| 973 | .and_then(|t| t.content()) | ||
| 974 | .ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))? | ||
| 975 | .to_string(); | ||
| 976 | |||
| 977 | // Build state event | ||
| 978 | let base_time = Timestamp::now().as_u64(); | ||
| 979 | let older_timestamp = Timestamp::from(base_time - 10); // 10 seconds ago | ||
| 980 | |||
| 981 | let state_event = self | ||
| 982 | .client | ||
| 983 | .event_builder(Kind::Custom(30618), "") | ||
| 984 | .tag(Tag::identifier(&repo_id)) | ||
| 985 | .tag(Tag::custom( | ||
| 986 | TagKind::custom("refs/heads/main"), | ||
| 987 | vec![DETERMINISTIC_COMMIT_HASH.to_string()], | ||
| 988 | )) | ||
| 989 | .tag(Tag::custom( | ||
| 990 | TagKind::custom("HEAD"), | ||
| 991 | vec!["ref: refs/heads/main".to_string()], | ||
| 992 | )) | ||
| 993 | .custom_time(older_timestamp) | ||
| 994 | .build(self.client.keys()) | ||
| 995 | .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))?; | ||
| 996 | |||
| 997 | // Send state event to relay | ||
| 998 | self.client.send_event(state_event.clone()).await?; | ||
| 999 | |||
| 1000 | // ============================================================ | ||
| 1001 | // Stage 3: Verify state event was accepted | ||
| 1002 | // ============================================================ | ||
| 1003 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | ||
| 1004 | |||
| 1005 | // ============================================================ | ||
| 1006 | // Stage 4: DataPushed - Clone repo, create commit, push | ||
| 1007 | // ============================================================ | ||
| 1008 | |||
| 1009 | // Get relay domain from connected relay | ||
| 1010 | let relay_domain = self.get_relay_domain().await?; | ||
| 1011 | |||
| 1012 | let npub = state_event | ||
| 1013 | .pubkey | ||
| 1014 | .to_bech32() | ||
| 1015 | .map_err(|e| anyhow::anyhow!("Failed to convert pubkey to bech32: {}", e))?; | ||
| 1016 | |||
| 1017 | // Clone the repository | ||
| 1018 | let clone_path = clone_repo(&relay_domain, &npub, &repo_id) | ||
| 1019 | .map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?; | ||
| 1020 | |||
| 1021 | // Cleanup helper (always clean up on error or success) | ||
| 1022 | let cleanup = |path: &PathBuf| { | ||
| 1023 | let _ = fs::remove_dir_all(path); | ||
| 1024 | }; | ||
| 1025 | |||
| 1026 | // Create deterministic commit locally | ||
| 1027 | let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") { | ||
| 1028 | Ok(h) => h, | ||
| 1029 | Err(e) => { | ||
| 1030 | cleanup(&clone_path); | ||
| 1031 | return Err(anyhow::anyhow!("Failed to create deterministic commit: {}", e)); | ||
| 1032 | } | ||
| 1033 | }; | ||
| 1034 | |||
| 1035 | // Verify commit hash matches expected | ||
| 1036 | if commit_hash != DETERMINISTIC_COMMIT_HASH { | ||
| 1037 | cleanup(&clone_path); | ||
| 1038 | return Err(anyhow::anyhow!( | ||
| 1039 | "Commit hash mismatch: got {}, expected {}", | ||
| 1040 | commit_hash, | ||
| 1041 | DETERMINISTIC_COMMIT_HASH | ||
| 1042 | )); | ||
| 1043 | } | ||
| 1044 | |||
| 1045 | // Create main branch pointing to our deterministic commit | ||
| 1046 | let branch_output = Command::new("git") | ||
| 1047 | .args(["branch", "main"]) | ||
| 1048 | .current_dir(&clone_path) | ||
| 1049 | .output(); | ||
| 1050 | |||
| 1051 | match branch_output { | ||
| 1052 | Err(e) => { | ||
| 1053 | cleanup(&clone_path); | ||
| 1054 | return Err(anyhow::anyhow!("Failed to create main branch: {}", e)); | ||
| 1055 | } | ||
| 1056 | Ok(output) if !output.status.success() => { | ||
| 1057 | cleanup(&clone_path); | ||
| 1058 | return Err(anyhow::anyhow!( | ||
| 1059 | "Failed to create main branch: {}", | ||
| 1060 | String::from_utf8_lossy(&output.stderr) | ||
| 1061 | )); | ||
| 1062 | } | ||
| 1063 | _ => {} | ||
| 1064 | } | ||
| 1065 | |||
| 1066 | // Checkout main branch | ||
| 1067 | let checkout_output = Command::new("git") | ||
| 1068 | .args(["checkout", "main"]) | ||
| 1069 | .current_dir(&clone_path) | ||
| 1070 | .output(); | ||
| 1071 | |||
| 1072 | match checkout_output { | ||
| 1073 | Err(e) => { | ||
| 1074 | cleanup(&clone_path); | ||
| 1075 | return Err(anyhow::anyhow!("Failed to checkout main branch: {}", e)); | ||
| 1076 | } | ||
| 1077 | Ok(output) if !output.status.success() => { | ||
| 1078 | cleanup(&clone_path); | ||
| 1079 | return Err(anyhow::anyhow!( | ||
| 1080 | "Failed to checkout main branch: {}", | ||
| 1081 | String::from_utf8_lossy(&output.stderr) | ||
| 1082 | )); | ||
| 1083 | } | ||
| 1084 | _ => {} | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | // Push to relay | ||
| 1088 | let push_result = try_push(&clone_path); | ||
| 1089 | cleanup(&clone_path); | ||
| 1090 | |||
| 1091 | match push_result { | ||
| 1092 | Ok(true) => Ok(state_event), | ||
| 1093 | Ok(false) => Err(anyhow::anyhow!( | ||
| 1094 | "Push was rejected but should have been accepted. \ | ||
| 1095 | The state event points to commit {} which matches the pushed commit.", | ||
| 1096 | DETERMINISTIC_COMMIT_HASH | ||
| 1097 | )), | ||
| 1098 | Err(e) => Err(anyhow::anyhow!("Push error: {}", e)), | ||
| 1099 | } | ||
| 1100 | } | ||
| 1101 | |||
| 1102 | /// Get relay domain (host:port) from the connected relay | ||
| 1103 | /// | ||
| 1104 | /// Extracts the domain from the relay URL for git HTTP operations. | ||
| 1105 | /// Example: ws://localhost:7000 -> localhost:7000 | ||
| 1106 | async fn get_relay_domain(&self) -> Result<String> { | ||
| 1107 | let relay_url = self | ||
| 1108 | .client | ||
| 1109 | .client() | ||
| 1110 | .relays() | ||
| 1111 | .await | ||
| 1112 | .keys() | ||
| 1113 | .next() | ||
| 1114 | .ok_or_else(|| anyhow::anyhow!("No relay connected"))? | ||
| 1115 | .to_string(); | ||
| 1116 | |||
| 1117 | // Extract domain from URL (ws://host:port -> host:port) | ||
| 1118 | let domain = relay_url | ||
| 1119 | .replace("ws://", "") | ||
| 1120 | .replace("wss://", "") | ||
| 1121 | .trim_end_matches('/') | ||
| 1122 | .to_string(); | ||
| 1123 | |||
| 1124 | Ok(domain) | ||
| 1125 | } | ||
| 1126 | |||
| 910 | /// Clear the fixture cache | 1127 | /// Clear the fixture cache |
| 911 | /// | 1128 | /// |
| 912 | /// This clears the client's fixture cache, affecting all TestContext | 1129 | /// This clears the client's fixture cache, affecting all TestContext |