upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/grasp-audit/src/fixtures.rs
diff options
context:
space:
mode:
Diffstat (limited to 'grasp-audit/src/fixtures.rs')
-rw-r--r--grasp-audit/src/fixtures.rs835
1 files changed, 835 insertions, 0 deletions
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs
index f7988a0..3e21eae 100644
--- a/grasp-audit/src/fixtures.rs
+++ b/grasp-audit/src/fixtures.rs
@@ -709,6 +709,841 @@ impl<'a> TestContext<'a> {
709 } 709 }
710} 710}
711 711
712// ============================================================
713// Verification Helpers
714// ============================================================
715
716/// Send event and verify it was accepted (stored by relay)
717///
718/// This is a common test pattern helper that:
719/// 1. Sends an event to the relay via the client
720/// 2. Waits for propagation (100ms)
721/// 3. Queries the relay to verify the event was stored
722///
723/// # Arguments
724/// * `client` - The AuditClient to use for sending and querying
725/// * `event` - The event to send
726/// * `description` - Human-readable description for error messages
727///
728/// # Returns
729/// * `Ok(())` if the event was accepted and stored
730/// * `Err(String)` with descriptive error if event was not stored
731///
732/// # Example
733/// ```no_run
734/// # use grasp_audit::*;
735/// # async fn example(client: &AuditClient, event: nostr_sdk::Event) -> Result<(), String> {
736/// send_and_verify_accepted(client, event, "issue referencing repo via 'a' tag").await?;
737/// # Ok(())
738/// # }
739/// ```
740pub async fn send_and_verify_accepted(
741 client: &crate::AuditClient,
742 event: Event,
743 description: &str,
744) -> Result<(), String> {
745 use nostr_sdk::prelude::Filter;
746 use std::time::Duration;
747
748 let event_id = event.id;
749
750 client
751 .send_event(event)
752 .await
753 .map_err(|e| format!("Failed to send event to relay: {}", e))?;
754
755 tokio::time::sleep(Duration::from_millis(100)).await;
756
757 let filter = Filter::new().id(event_id);
758 let events = client
759 .query(filter)
760 .await
761 .map_err(|e| format!("Failed to query relay for verification: {}", e))?;
762
763 if events.is_empty() {
764 return Err(format!("Event should be accepted: {}", description));
765 }
766
767 Ok(())
768}
769
770/// Send event and verify it was rejected (NOT stored by relay)
771///
772/// This is a common test pattern helper that:
773/// 1. Sends an event to the relay via the client
774/// 2. Handles both explicit rejection errors and silent rejection
775/// 3. Verifies the event was NOT stored in the relay
776///
777/// # Arguments
778/// * `client` - The AuditClient to use for sending and querying
779/// * `event` - The event to send (expected to be rejected)
780/// * `description` - Human-readable description for error messages
781///
782/// # Returns
783/// * `Ok(())` if the event was rejected (not stored)
784/// * `Err(String)` if the event was unexpectedly accepted
785///
786/// # Example
787/// ```no_run
788/// # use grasp_audit::*;
789/// # async fn example(client: &AuditClient, event: nostr_sdk::Event) -> Result<(), String> {
790/// send_and_verify_rejected(client, event, "orphan issue with no repo connection").await?;
791/// # Ok(())
792/// # }
793/// ```
794pub async fn send_and_verify_rejected(
795 client: &crate::AuditClient,
796 event: Event,
797 description: &str,
798) -> Result<(), String> {
799 use nostr_sdk::prelude::Filter;
800 use std::time::Duration;
801
802 let event_id = event.id;
803
804 // Try to send event - rejection may cause send_event to fail with an error
805 let send_result = client.send_event(event).await;
806
807 // If send succeeded, the relay might have accepted it (we'll verify below)
808 // If send failed, check if it's a rejection error (expected)
809 if let Err(e) = send_result {
810 let err_msg = e.to_string().to_lowercase();
811 // Check if error message indicates rejection (not network/other errors)
812 if err_msg.contains("rejected") || err_msg.contains("blocked") {
813 // Expected rejection - verify event is NOT in database
814 tokio::time::sleep(Duration::from_millis(100)).await;
815
816 let filter = Filter::new().id(event_id);
817 let events = client
818 .query(filter)
819 .await
820 .map_err(|e| format!("Failed to query relay for verification: {}", e))?;
821
822 if !events.is_empty() {
823 return Err(format!("Event was rejected but still stored: {}", description));
824 }
825
826 return Ok(()); // Rejected as expected
827 } else {
828 // Unexpected error (network, etc.)
829 return Err(format!("Failed to send event to relay: {}", e));
830 }
831 }
832
833 // Send succeeded, verify event was NOT stored (relay should have rejected)
834 tokio::time::sleep(Duration::from_millis(100)).await;
835
836 let filter = Filter::new().id(event_id);
837 let events = client
838 .query(filter)
839 .await
840 .map_err(|e| format!("Failed to query relay for verification: {}", e))?;
841
842 if !events.is_empty() {
843 return Err(format!("Event should be rejected: {}", description));
844 }
845
846 Ok(())
847}
848
849// ============================================================
850// Git Operation Helpers
851// ============================================================
852
853use nostr_sdk::ToBech32;
854use std::fs;
855use std::path::{Path, PathBuf};
856use std::process::Command;
857
858/// Clone a repository from the relay and return the path
859///
860/// # Arguments
861/// * `relay_domain` - The domain of the relay (e.g., "localhost:7000")
862/// * `npub` - The bech32 public key of the repository owner
863/// * `repo_id` - The repository identifier (d-tag value)
864///
865/// # Returns
866/// * `Ok(PathBuf)` - Path to the cloned repository
867/// * `Err(String)` - Error message if clone failed
868///
869/// # Example
870/// ```no_run
871/// # use grasp_audit::*;
872/// # fn example() -> Result<(), String> {
873/// let clone_path = clone_repo("localhost:7000", "npub1...", "my-repo")?;
874/// // Use the cloned repo...
875/// std::fs::remove_dir_all(&clone_path).ok(); // Cleanup
876/// # Ok(())
877/// # }
878/// ```
879pub fn clone_repo(
880 relay_domain: &str,
881 npub: &str,
882 repo_id: &str,
883) -> Result<PathBuf, String> {
884 let temp_base = std::env::temp_dir();
885 let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4());
886 let clone_path = temp_base.join(&clone_dir_name);
887 let _ = fs::remove_dir_all(&clone_path);
888
889 let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
890 let output = Command::new("git")
891 .args(["clone", &clone_url, clone_path.to_str().unwrap()])
892 .env("GIT_TERMINAL_PROMPT", "0")
893 .output()
894 .map_err(|e| format!("Failed to execute git clone: {}", e))?;
895
896 if !output.status.success() {
897 let stderr = String::from_utf8_lossy(&output.stderr);
898 return Err(format!("Git clone failed: {}", stderr));
899 }
900
901 // Configure git user
902 let _ = Command::new("git")
903 .args(["config", "user.email", "test@grasp-audit.local"])
904 .current_dir(&clone_path)
905 .output();
906 let _ = Command::new("git")
907 .args(["config", "user.name", "GRASP Audit Test"])
908 .current_dir(&clone_path)
909 .output();
910
911 Ok(clone_path)
912}
913
914/// Create a commit with a unique file and return the commit hash
915///
916/// # Arguments
917/// * `clone_path` - Path to the git repository
918/// * `message` - Commit message
919///
920/// # Returns
921/// * `Ok(String)` - The commit hash
922/// * `Err(String)` - Error message if commit failed
923///
924/// # Example
925/// ```no_run
926/// # use grasp_audit::*;
927/// # use std::path::Path;
928/// # fn example() -> Result<(), String> {
929/// let commit_hash = create_commit(Path::new("/tmp/my-repo"), "My commit message")?;
930/// println!("Created commit: {}", commit_hash);
931/// # Ok(())
932/// # }
933/// ```
934pub fn create_commit(clone_path: &Path, message: &str) -> Result<String, String> {
935 let test_file = clone_path.join(format!("test-{}.txt", uuid::Uuid::new_v4()));
936 fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?;
937
938 let filename = test_file.file_name().unwrap().to_str().unwrap();
939 let output = Command::new("git")
940 .args(["add", filename])
941 .current_dir(clone_path)
942 .output()
943 .map_err(|e| format!("Git add failed: {}", e))?;
944
945 if !output.status.success() {
946 return Err("Git add failed".to_string());
947 }
948
949 let output = Command::new("git")
950 .args(["commit", "-m", message])
951 .current_dir(clone_path)
952 .output()
953 .map_err(|e| format!("Git commit failed: {}", e))?;
954
955 if !output.status.success() {
956 return Err("Git commit failed".to_string());
957 }
958
959 let output = Command::new("git")
960 .args(["rev-parse", "HEAD"])
961 .current_dir(clone_path)
962 .output()
963 .map_err(|e| format!("Git rev-parse failed: {}", e))?;
964
965 if !output.status.success() {
966 return Err("Failed to get commit hash".to_string());
967 }
968
969 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
970}
971
972/// Variant of deterministic commit for different pubkey types
973/// Each variant produces a different but reproducible commit hash
974#[derive(Debug, Clone, Copy, PartialEq, Eq)]
975pub enum CommitVariant {
976 /// Main pubkey variant - uses "Initial commit" content
977 Owner,
978 /// Maintainer pubkey variant - uses "Maintainer initial commit" content
979 Maintainer,
980 /// Recursive maintainer pubkey variant - uses "Recursive maintainer initial commit" content
981 RecursiveMaintainer,
982}
983
984impl CommitVariant {
985 /// Get the file content for this variant
986 pub fn file_content(&self) -> &'static str {
987 match self {
988 CommitVariant::Owner => "Initial commit",
989 CommitVariant::Maintainer => "Maintainer initial commit",
990 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
991 }
992 }
993
994 /// Get the commit message for this variant
995 pub fn commit_message(&self) -> &'static str {
996 match self {
997 CommitVariant::Owner => "Initial commit",
998 CommitVariant::Maintainer => "Maintainer initial commit",
999 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
1000 }
1001 }
1002}
1003
1004/// Create a deterministic commit with fixed dates and GPG disabled
1005///
1006/// The variant parameter allows different commit hashes for different pubkey types:
1007/// - Owner: uses DETERMINISTIC_COMMIT_HASH
1008/// - Maintainer: uses MAINTAINER_DETERMINISTIC_COMMIT_HASH
1009/// - RecursiveMaintainer: uses RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
1010///
1011/// # Arguments
1012/// * `clone_path` - Path to the git repository
1013/// * `variant` - The commit variant to create
1014///
1015/// # Returns
1016/// * `Ok(String)` - The deterministic commit hash
1017/// * `Err(String)` - Error message if commit failed
1018pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result<String, String> {
1019 let test_file = clone_path.join("test.txt");
1020 let content = variant.file_content();
1021 let message = variant.commit_message();
1022
1023 fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?;
1024
1025 let output = Command::new("git")
1026 .args(["add", "test.txt"])
1027 .current_dir(clone_path)
1028 .output()
1029 .map_err(|e| format!("Git add failed: {}", e))?;
1030
1031 if !output.status.success() {
1032 return Err("Git add failed".to_string());
1033 }
1034
1035 // Create deterministic commit with fixed dates and GPG disabled
1036 let output = Command::new("git")
1037 .args([
1038 "-c", "commit.gpgsign=false",
1039 "commit",
1040 "-m", message,
1041 ])
1042 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z")
1043 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z")
1044 .current_dir(clone_path)
1045 .output()
1046 .map_err(|e| format!("Git commit failed: {}", e))?;
1047
1048 if !output.status.success() {
1049 let stderr = String::from_utf8_lossy(&output.stderr);
1050 return Err(format!("Git commit failed: {}", stderr));
1051 }
1052
1053 let output = Command::new("git")
1054 .args(["rev-parse", "HEAD"])
1055 .current_dir(clone_path)
1056 .output()
1057 .map_err(|e| format!("Git rev-parse failed: {}", e))?;
1058
1059 if !output.status.success() {
1060 return Err("Failed to get commit hash".to_string());
1061 }
1062
1063 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1064}
1065
1066/// Create a deterministic commit (Owner variant)
1067///
1068/// This is a convenience wrapper around `create_deterministic_commit_with_variant`
1069/// that uses the Owner variant for backwards compatibility.
1070///
1071/// # Arguments
1072/// * `clone_path` - Path to the git repository
1073/// * `_message` - Ignored for compatibility (Owner variant always uses "Initial commit")
1074///
1075/// # Returns
1076/// * `Ok(String)` - The deterministic commit hash
1077/// * `Err(String)` - Error message if commit failed
1078pub fn create_deterministic_commit(clone_path: &Path, _message: &str) -> Result<String, String> {
1079 // Note: message parameter is ignored for backwards compatibility
1080 // The Owner variant always uses "Initial commit"
1081 create_deterministic_commit_with_variant(clone_path, CommitVariant::Owner)
1082}
1083
1084/// Repository setup with deterministic commit
1085/// This struct holds all the data needed for push authorization tests
1086pub struct RepoSetup {
1087 /// Path to the cloned repository (auto-cleaned on drop)
1088 pub clone_path: PathBuf,
1089 /// Repository identifier (d-tag value)
1090 pub repo_id: String,
1091 /// Owner's bech32 public key
1092 pub npub: String,
1093 /// The deterministic commit hash
1094 pub commit_hash: String,
1095}
1096
1097impl Drop for RepoSetup {
1098 fn drop(&mut self) {
1099 let _ = fs::remove_dir_all(&self.clone_path);
1100 }
1101}
1102
1103/// Set up a repository with deterministic commit for testing
1104///
1105/// This performs all the common setup steps needed for push authorization tests:
1106/// 1. Gets RepoState fixture (repo announcement + state event with deterministic commit)
1107/// 2. Extracts repo_id and npub
1108/// 3. Verifies repo exists on disk
1109/// 4. Clones the repository
1110/// 5. Creates deterministic commit locally
1111/// 6. Verifies commit hash matches expected
1112/// 7. Creates and checks out main branch
1113/// 8. Pushes the commit so the grasp server has the state in the state event
1114///
1115/// Returns RepoSetup which auto-cleans up the clone_path on drop
1116///
1117/// # Arguments
1118/// * `client` - The AuditClient to use for fixtures
1119/// * `git_data_dir` - Path to the git data directory
1120/// * `relay_domain` - The domain of the relay (e.g., "localhost:7000")
1121///
1122/// # Returns
1123/// * `Ok(RepoSetup)` - The setup data
1124/// * `Err(String)` - Error message if setup failed
1125pub async fn setup_repo_with_deterministic_commit(
1126 client: &crate::AuditClient,
1127 git_data_dir: &Path,
1128 relay_domain: &str,
1129) -> Result<RepoSetup, String> {
1130 use nostr_sdk::prelude::TagKind;
1131
1132 let ctx = TestContext::new(client);
1133
1134 // Get RepoState fixture (includes repo announcement and state event with deterministic commit)
1135 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
1136 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
1137
1138 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1139
1140 // Extract repo_id from state event
1141 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
1142 .and_then(|t| t.content())
1143 .ok_or("Missing repo_id")?
1144 .to_string();
1145 let npub = state_event.pubkey.to_bech32()
1146 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
1147
1148 // Verify repo exists
1149 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
1150 if !repo_path.exists() {
1151 return Err(format!("Repo not found: {}", repo_path.display()));
1152 }
1153
1154 // Clone repo
1155 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
1156
1157 // Create deterministic commit locally (this will be the root commit with no parent)
1158 let commit_hash = create_deterministic_commit(&clone_path, "Initial commit")
1159 .map_err(|e| {
1160 let _ = fs::remove_dir_all(&clone_path);
1161 e
1162 })?;
1163
1164 // Verify commit hash matches expected deterministic hash
1165 if commit_hash != DETERMINISTIC_COMMIT_HASH {
1166 let _ = fs::remove_dir_all(&clone_path);
1167 return Err(format!(
1168 "Commit hash mismatch: got {}, expected {}",
1169 commit_hash, DETERMINISTIC_COMMIT_HASH
1170 ));
1171 }
1172
1173 // Create main branch pointing to our deterministic commit
1174 let branch_output = Command::new("git")
1175 .args(["branch", "main"])
1176 .current_dir(&clone_path)
1177 .output()
1178 .map_err(|e| {
1179 let _ = fs::remove_dir_all(&clone_path);
1180 format!("Failed to create main branch: {}", e)
1181 })?;
1182
1183 if !branch_output.status.success() {
1184 let _ = fs::remove_dir_all(&clone_path);
1185 return Err(format!(
1186 "Failed to create main branch: {}",
1187 String::from_utf8_lossy(&branch_output.stderr)
1188 ));
1189 }
1190
1191 // Checkout main branch
1192 let checkout_output = Command::new("git")
1193 .args(["checkout", "main"])
1194 .current_dir(&clone_path)
1195 .output()
1196 .map_err(|e| {
1197 let _ = fs::remove_dir_all(&clone_path);
1198 format!("Failed to checkout main branch: {}", e)
1199 })?;
1200
1201 if !checkout_output.status.success() {
1202 let _ = fs::remove_dir_all(&clone_path);
1203 return Err(format!(
1204 "Failed to checkout main branch: {}",
1205 String::from_utf8_lossy(&checkout_output.stderr)
1206 ));
1207 }
1208
1209 // Push the commit to the server so the bare repo matches the state event
1210 let push_output = Command::new("git")
1211 .args(["push", "origin", "main"])
1212 .current_dir(&clone_path)
1213 .env("GIT_TERMINAL_PROMPT", "0")
1214 .output()
1215 .map_err(|e| {
1216 let _ = fs::remove_dir_all(&clone_path);
1217 format!("Failed to push to server: {}", e)
1218 })?;
1219
1220 if !push_output.status.success() {
1221 let _ = fs::remove_dir_all(&clone_path);
1222 return Err(format!(
1223 "Failed to push to server: {}",
1224 String::from_utf8_lossy(&push_output.stderr)
1225 ));
1226 }
1227
1228 Ok(RepoSetup {
1229 clone_path,
1230 repo_id,
1231 npub,
1232 commit_hash,
1233 })
1234}
1235
1236/// Set up a maintainer repository with deterministic commit (state only)
1237///
1238/// This performs all the common setup steps needed for maintainer push authorization tests:
1239/// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit)
1240/// 2. Gets MaintainerState fixture (maintainer's state event ONLY - no announcement)
1241/// 3. Extracts repo_id and owner npub
1242/// 4. Verifies repo exists on disk
1243/// 5. Clones the repository using owner's npub
1244/// 6. Creates maintainer deterministic commit locally
1245/// 7. Verifies commit hash matches expected
1246/// 8. Creates and checks out main branch
1247/// 9. Pushes the commit so the grasp server has the state in the state event
1248///
1249/// Note: This does NOT publish a maintainer announcement. For tests that need the
1250/// maintainer announcement (like recursive maintainer tests), use setup_repo_for_recursive_maintainer
1251/// which publishes MaintainerAnnouncement separately.
1252///
1253/// Returns RepoSetup which auto-cleans up the clone_path on drop
1254pub async fn setup_repo_for_maintainer(
1255 client: &crate::AuditClient,
1256 git_data_dir: &Path,
1257 relay_domain: &str,
1258) -> Result<RepoSetup, String> {
1259 use nostr_sdk::prelude::TagKind;
1260
1261 let ctx = TestContext::new(client);
1262
1263 // Get RepoState fixture (includes owner's repo announcement and state event with owner's deterministic commit)
1264 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
1265 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
1266
1267 // Get MaintainerState fixture ONLY (no announcement - tests state-only authorization)
1268 let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await
1269 .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?;
1270
1271 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1272
1273 // Extract repo_id from state event
1274 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
1275 .and_then(|t| t.content())
1276 .ok_or("Missing repo_id")?
1277 .to_string();
1278
1279 // The npub is from the owner keys (the signer of the state event)
1280 let npub = state_event.pubkey.to_bech32()
1281 .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?;
1282
1283 // Verify repo exists
1284 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
1285 if !repo_path.exists() {
1286 return Err(format!("Owner repo not found: {}", repo_path.display()));
1287 }
1288
1289 // Clone repo using owner's npub
1290 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
1291
1292 // Create maintainer deterministic commit locally (this will be the root commit with no parent)
1293 let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer)
1294 .map_err(|e| {
1295 let _ = fs::remove_dir_all(&clone_path);
1296 e
1297 })?;
1298
1299 // Verify commit hash matches expected maintainer deterministic hash
1300 if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH {
1301 let _ = fs::remove_dir_all(&clone_path);
1302 return Err(format!(
1303 "Maintainer commit hash mismatch: got {}, expected {}",
1304 commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH
1305 ));
1306 }
1307
1308 // Create main branch pointing to our deterministic commit
1309 let branch_output = Command::new("git")
1310 .args(["branch", "main"])
1311 .current_dir(&clone_path)
1312 .output()
1313 .map_err(|e| {
1314 let _ = fs::remove_dir_all(&clone_path);
1315 format!("Failed to create main branch: {}", e)
1316 })?;
1317
1318 if !branch_output.status.success() {
1319 let _ = fs::remove_dir_all(&clone_path);
1320 return Err(format!(
1321 "Failed to create main branch: {}",
1322 String::from_utf8_lossy(&branch_output.stderr)
1323 ));
1324 }
1325
1326 // Checkout main branch
1327 let checkout_output = Command::new("git")
1328 .args(["checkout", "main"])
1329 .current_dir(&clone_path)
1330 .output()
1331 .map_err(|e| {
1332 let _ = fs::remove_dir_all(&clone_path);
1333 format!("Failed to checkout main branch: {}", e)
1334 })?;
1335
1336 if !checkout_output.status.success() {
1337 let _ = fs::remove_dir_all(&clone_path);
1338 return Err(format!(
1339 "Failed to checkout main branch: {}",
1340 String::from_utf8_lossy(&checkout_output.stderr)
1341 ));
1342 }
1343
1344 // Push the commit to the server so the bare repo matches the state event
1345 let push_output = Command::new("git")
1346 .args(["push", "origin", "main"])
1347 .current_dir(&clone_path)
1348 .env("GIT_TERMINAL_PROMPT", "0")
1349 .output()
1350 .map_err(|e| {
1351 let _ = fs::remove_dir_all(&clone_path);
1352 format!("Failed to push to server: {}", e)
1353 })?;
1354
1355 if !push_output.status.success() {
1356 let _ = fs::remove_dir_all(&clone_path);
1357 return Err(format!(
1358 "Failed to push to server: {}",
1359 String::from_utf8_lossy(&push_output.stderr)
1360 ));
1361 }
1362
1363 Ok(RepoSetup {
1364 clone_path,
1365 repo_id,
1366 npub,
1367 commit_hash,
1368 })
1369}
1370
1371/// Set up a recursive maintainer repository with deterministic commit
1372///
1373/// This performs all the common setup steps needed for recursive maintainer push authorization tests:
1374/// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit)
1375/// 2. Gets MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag)
1376/// 3. Gets MaintainerState fixture (maintainer's state event)
1377/// 4. Gets RecursiveMaintainerRepoAndState fixture (recursive maintainer's repo - completes 3-level chain)
1378/// 5. Extracts repo_id and owner npub
1379/// 6. Verifies repo exists on disk
1380/// 7. Clones the repository using owner's npub
1381/// 8. Creates recursive maintainer deterministic commit locally
1382/// 9. Verifies commit hash matches expected
1383/// 10. Creates and checks out main branch
1384/// 11. Pushes the commit so the grasp server has the state in the state event
1385///
1386/// Returns RepoSetup which auto-cleans up the clone_path on drop
1387pub async fn setup_repo_for_recursive_maintainer(
1388 client: &crate::AuditClient,
1389 git_data_dir: &Path,
1390 relay_domain: &str,
1391) -> Result<RepoSetup, String> {
1392 use nostr_sdk::prelude::TagKind;
1393
1394 let ctx = TestContext::new(client);
1395
1396 // Get RepoState fixture (includes owner's repo announcement and state event)
1397 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
1398 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
1399
1400 // Get MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag)
1401 let _maintainer_announcement = ctx.get_fixture(FixtureKind::MaintainerAnnouncement).await
1402 .map_err(|e| format!("Failed to create maintainer announcement fixture: {}", e))?;
1403
1404 // Get MaintainerState fixture (maintainer's state event)
1405 let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await
1406 .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?;
1407
1408 // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain)
1409 let _recursive_maintainer_state = ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await
1410 .map_err(|e| format!("Failed to create recursive maintainer repo state fixture: {}", e))?;
1411
1412 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1413
1414 // Extract repo_id from owner's state event
1415 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
1416 .and_then(|t| t.content())
1417 .ok_or("Missing repo_id")?
1418 .to_string();
1419
1420 // The npub is from the owner keys (the signer of the state event)
1421 let npub = state_event.pubkey.to_bech32()
1422 .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?;
1423
1424 // Verify repo exists
1425 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
1426 if !repo_path.exists() {
1427 return Err(format!("Owner repo not found: {}", repo_path.display()));
1428 }
1429
1430 // Clone repo using owner's npub
1431 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
1432
1433 // Create recursive maintainer deterministic commit locally (this will be the root commit with no parent)
1434 let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer)
1435 .map_err(|e| {
1436 let _ = fs::remove_dir_all(&clone_path);
1437 e
1438 })?;
1439
1440 // Verify commit hash matches expected recursive maintainer deterministic hash
1441 if commit_hash != RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH {
1442 let _ = fs::remove_dir_all(&clone_path);
1443 return Err(format!(
1444 "Recursive maintainer commit hash mismatch: got {}, expected {}",
1445 commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
1446 ));
1447 }
1448
1449 // Create main branch pointing to our deterministic commit
1450 let branch_output = Command::new("git")
1451 .args(["branch", "main"])
1452 .current_dir(&clone_path)
1453 .output()
1454 .map_err(|e| {
1455 let _ = fs::remove_dir_all(&clone_path);
1456 format!("Failed to create main branch: {}", e)
1457 })?;
1458
1459 if !branch_output.status.success() {
1460 let _ = fs::remove_dir_all(&clone_path);
1461 return Err(format!(
1462 "Failed to create main branch: {}",
1463 String::from_utf8_lossy(&branch_output.stderr)
1464 ));
1465 }
1466
1467 // Checkout main branch
1468 let checkout_output = Command::new("git")
1469 .args(["checkout", "main"])
1470 .current_dir(&clone_path)
1471 .output()
1472 .map_err(|e| {
1473 let _ = fs::remove_dir_all(&clone_path);
1474 format!("Failed to checkout main branch: {}", e)
1475 })?;
1476
1477 if !checkout_output.status.success() {
1478 let _ = fs::remove_dir_all(&clone_path);
1479 return Err(format!(
1480 "Failed to checkout main branch: {}",
1481 String::from_utf8_lossy(&checkout_output.stderr)
1482 ));
1483 }
1484
1485 // Push the commit to the server so the bare repo matches the state event
1486 let push_output = Command::new("git")
1487 .args(["push", "origin", "main"])
1488 .current_dir(&clone_path)
1489 .env("GIT_TERMINAL_PROMPT", "0")
1490 .output()
1491 .map_err(|e| {
1492 let _ = fs::remove_dir_all(&clone_path);
1493 format!("Failed to push to server: {}", e)
1494 })?;
1495
1496 if !push_output.status.success() {
1497 let _ = fs::remove_dir_all(&clone_path);
1498 return Err(format!(
1499 "Failed to push to server: {}",
1500 String::from_utf8_lossy(&push_output.stderr)
1501 ));
1502 }
1503
1504 Ok(RepoSetup {
1505 clone_path,
1506 repo_id,
1507 npub,
1508 commit_hash,
1509 })
1510}
1511
1512/// Attempt a git push and return success/failure
1513///
1514/// # Arguments
1515/// * `clone_path` - Path to the git repository
1516///
1517/// # Returns
1518/// * `Ok(true)` - Push succeeded
1519/// * `Ok(false)` - Push was rejected
1520/// * `Err(String)` - Error executing git push
1521///
1522/// # Example
1523/// ```no_run
1524/// # use grasp_audit::*;
1525/// # use std::path::Path;
1526/// # fn example() -> Result<(), String> {
1527/// let success = try_push(Path::new("/tmp/my-repo"))?;
1528/// if success {
1529/// println!("Push succeeded");
1530/// } else {
1531/// println!("Push was rejected");
1532/// }
1533/// # Ok(())
1534/// # }
1535/// ```
1536pub fn try_push(clone_path: &Path) -> Result<bool, String> {
1537 let output = Command::new("git")
1538 .args(["push", "origin", "main"])
1539 .current_dir(clone_path)
1540 .env("GIT_TERMINAL_PROMPT", "0")
1541 .output()
1542 .map_err(|e| format!("Failed to execute git push: {}", e))?;
1543
1544 Ok(output.status.success())
1545}
1546
712#[cfg(test)] 1547#[cfg(test)]
713mod tests { 1548mod tests {
714 use super::*; 1549 use super::*;