upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-27 15:16:58 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-27 15:16:58 +0000
commit233feae6af4b291e4860a1ddf9df2ccf82e57c2f (patch)
tree569d798c72f3219d7d4a7035724045a624d0fc2a /grasp-audit/src
parent3f18235e4d2b881b7bac543878cdf501abfe667e (diff)
fix(tests): update main project tests for grasp-audit API changes
Diffstat (limited to 'grasp-audit/src')
-rw-r--r--grasp-audit/src/fixtures.rs835
-rw-r--r--grasp-audit/src/lib.rs7
-rw-r--r--grasp-audit/src/specs/grasp01/mod.rs2
-rw-r--r--grasp-audit/src/specs/grasp01/push_authorization.rs606
4 files changed, 847 insertions, 603 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::*;
diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs
index b7ce992..a58c2f0 100644
--- a/grasp-audit/src/lib.rs
+++ b/grasp-audit/src/lib.rs
@@ -38,8 +38,11 @@ pub mod specs;
38pub use audit::{AuditConfig, AuditMode}; 38pub use audit::{AuditConfig, AuditMode};
39pub use client::AuditClient; 39pub use client::AuditClient;
40pub use fixtures::{ 40pub use fixtures::{
41 ContextMode, FixtureKind, TestContext, DETERMINISTIC_COMMIT_HASH, 41 clone_repo, create_commit, create_deterministic_commit, create_deterministic_commit_with_variant,
42 MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, 42 CommitVariant, ContextMode, FixtureKind, RepoSetup, TestContext,
43 setup_repo_for_maintainer, setup_repo_for_recursive_maintainer, setup_repo_with_deterministic_commit,
44 try_push, DETERMINISTIC_COMMIT_HASH, MAINTAINER_DETERMINISTIC_COMMIT_HASH,
45 RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH,
43}; 46};
44pub use result::{AuditResult, TestResult}; 47pub use result::{AuditResult, TestResult};
45 48
diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs
index 5ce5eca..0d0bd9c 100644
--- a/grasp-audit/src/specs/grasp01/mod.rs
+++ b/grasp-audit/src/specs/grasp01/mod.rs
@@ -13,5 +13,5 @@ pub use event_acceptance_policy::EventAcceptancePolicyTests;
13pub use git_clone::GitCloneTests; 13pub use git_clone::GitCloneTests;
14pub use nip01_smoke::Nip01SmokeTests; 14pub use nip01_smoke::Nip01SmokeTests;
15pub use nip11_document::Nip11DocumentTests; 15pub use nip11_document::Nip11DocumentTests;
16pub use push_authorization::{CommitVariant, PushAuthorizationTests}; 16pub use push_authorization::PushAuthorizationTests;
17pub use repository_creation::RepositoryCreationTests; 17pub use repository_creation::RepositoryCreationTests;
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs
index cba9e69..d58247d 100644
--- a/grasp-audit/src/specs/grasp01/push_authorization.rs
+++ b/grasp-audit/src/specs/grasp01/push_authorization.rs
@@ -16,612 +16,18 @@
16//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test 16//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
17//! ``` 17//! ```
18 18
19use crate::{AuditClient, FixtureKind, TestContext, TestResult}; 19use crate::{
20 clone_repo, create_commit, setup_repo_for_maintainer, setup_repo_for_recursive_maintainer,
21 setup_repo_with_deterministic_commit, try_push, AuditClient, FixtureKind, TestContext,
22 TestResult,
23};
20use nostr_sdk::prelude::*; 24use nostr_sdk::prelude::*;
21use std::fs; 25use std::fs;
22use std::path::{Path, PathBuf}; 26use std::path::Path;
23use std::process::Command;
24 27
25/// Test suite for Push Authorization operations 28/// Test suite for Push Authorization operations
26pub struct PushAuthorizationTests; 29pub struct PushAuthorizationTests;
27 30
28/// Helper to clone a repository and return the path
29fn clone_repo(
30 relay_domain: &str,
31 npub: &str,
32 repo_id: &str,
33) -> Result<std::path::PathBuf, String> {
34 let temp_base = std::env::temp_dir();
35 let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4());
36 let clone_path = temp_base.join(&clone_dir_name);
37 let _ = fs::remove_dir_all(&clone_path);
38
39 let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
40 let output = Command::new("git")
41 .args(["clone", &clone_url, clone_path.to_str().unwrap()])
42 .env("GIT_TERMINAL_PROMPT", "0")
43 .output()
44 .map_err(|e| format!("Failed to execute git clone: {}", e))?;
45
46 if !output.status.success() {
47 let stderr = String::from_utf8_lossy(&output.stderr);
48 return Err(format!("Git clone failed: {}", stderr));
49 }
50
51 // Configure git user
52 let _ = Command::new("git")
53 .args(["config", "user.email", "test@grasp-audit.local"])
54 .current_dir(&clone_path)
55 .output();
56 let _ = Command::new("git")
57 .args(["config", "user.name", "GRASP Audit Test"])
58 .current_dir(&clone_path)
59 .output();
60
61 Ok(clone_path)
62}
63
64/// Helper to create a commit and return the hash
65fn create_commit(clone_path: &Path, message: &str) -> Result<String, String> {
66 let test_file = clone_path.join(format!("test-{}.txt", uuid::Uuid::new_v4()));
67 fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?;
68
69 let filename = test_file.file_name().unwrap().to_str().unwrap();
70 let output = Command::new("git")
71 .args(["add", filename])
72 .current_dir(clone_path)
73 .output()
74 .map_err(|e| format!("Git add failed: {}", e))?;
75
76 if !output.status.success() {
77 return Err("Git add failed".to_string());
78 }
79
80 let output = Command::new("git")
81 .args(["commit", "-m", message])
82 .current_dir(clone_path)
83 .output()
84 .map_err(|e| format!("Git commit failed: {}", e))?;
85
86 if !output.status.success() {
87 return Err("Git commit failed".to_string());
88 }
89
90 let output = Command::new("git")
91 .args(["rev-parse", "HEAD"])
92 .current_dir(clone_path)
93 .output()
94 .map_err(|e| format!("Git rev-parse failed: {}", e))?;
95
96 if !output.status.success() {
97 return Err("Failed to get commit hash".to_string());
98 }
99
100 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
101}
102
103/// Variant of deterministic commit for different pubkey types
104/// Each variant produces a different but reproducible commit hash
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum CommitVariant {
107 /// Main pubkey variant - uses "Initial commit" content
108 Owner,
109 /// Maintainer pubkey variant - uses "Maintainer initial commit" content
110 Maintainer,
111 /// Recursive maintainer pubkey variant - uses "Recursive maintainer initial commit" content
112 RecursiveMaintainer,
113}
114
115impl CommitVariant {
116 /// Get the file content for this variant
117 pub fn file_content(&self) -> &'static str {
118 match self {
119 CommitVariant::Owner => "Initial commit",
120 CommitVariant::Maintainer => "Maintainer initial commit",
121 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
122 }
123 }
124
125 /// Get the commit message for this variant
126 pub fn commit_message(&self) -> &'static str {
127 match self {
128 CommitVariant::Owner => "Initial commit",
129 CommitVariant::Maintainer => "Maintainer initial commit",
130 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
131 }
132 }
133}
134
135/// Helper to create a deterministic commit (for fixtures)
136/// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash
137///
138/// The variant parameter allows different commit hashes for different pubkey types:
139/// - Owner: uses the original DETERMINISTIC_COMMIT_HASH
140/// - Maintainer: uses MAINTAINER_DETERMINISTIC_COMMIT_HASH
141/// - RecursiveMaintainer: uses RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
142pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result<String, String> {
143 let test_file = clone_path.join("test.txt");
144 let content = variant.file_content();
145 let message = variant.commit_message();
146
147 fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?;
148
149 let output = Command::new("git")
150 .args(["add", "test.txt"])
151 .current_dir(clone_path)
152 .output()
153 .map_err(|e| format!("Git add failed: {}", e))?;
154
155 if !output.status.success() {
156 return Err("Git add failed".to_string());
157 }
158
159 // Create deterministic commit with fixed dates and GPG disabled
160 let output = Command::new("git")
161 .args([
162 "-c", "commit.gpgsign=false",
163 "commit",
164 "-m", message,
165 ])
166 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z")
167 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z")
168 .current_dir(clone_path)
169 .output()
170 .map_err(|e| format!("Git commit failed: {}", e))?;
171
172 if !output.status.success() {
173 let stderr = String::from_utf8_lossy(&output.stderr);
174 return Err(format!("Git commit failed: {}", stderr));
175 }
176
177 let output = Command::new("git")
178 .args(["rev-parse", "HEAD"])
179 .current_dir(clone_path)
180 .output()
181 .map_err(|e| format!("Git rev-parse failed: {}", e))?;
182
183 if !output.status.success() {
184 return Err("Failed to get commit hash".to_string());
185 }
186
187 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
188}
189
190/// Helper to create a deterministic commit (for fixtures) - uses Owner variant
191/// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash
192pub fn create_deterministic_commit(clone_path: &Path, _message: &str) -> Result<String, String> {
193 // Note: message parameter is ignored for backwards compatibility
194 // The Owner variant always uses "Initial commit"
195 create_deterministic_commit_with_variant(clone_path, CommitVariant::Owner)
196}
197
198/// Repository setup with deterministic commit
199/// This struct holds all the data needed for push authorization tests
200pub struct RepoSetup {
201 pub clone_path: PathBuf,
202 pub repo_id: String,
203 pub npub: String,
204 pub commit_hash: String,
205}
206
207impl Drop for RepoSetup {
208 fn drop(&mut self) {
209 let _ = fs::remove_dir_all(&self.clone_path);
210 }
211}
212
213/// Helper function to set up a repository with deterministic commit
214///
215/// This performs all the common setup steps needed for push authorization tests:
216/// 1. Gets RepoState fixture (repo announcement + state event with deterministic commit)
217/// 2. Extracts repo_id and npub
218/// 3. Verifies repo exists on disk
219/// 4. Clones the repository
220/// 5. Creates deterministic commit locally
221/// 6. Verifies commit hash matches expected
222/// 7. Creates and checks out main branch
223/// 8. Pushes the commit so the grasp server has the state in the state event
224///
225/// Returns RepoSetup which auto-cleans up the clone_path on drop
226pub async fn setup_repo_with_deterministic_commit(
227 client: &AuditClient,
228 git_data_dir: &Path,
229 relay_domain: &str,
230) -> Result<RepoSetup, String> {
231 use crate::DETERMINISTIC_COMMIT_HASH;
232
233 let ctx = TestContext::new(client);
234
235 // Get RepoState fixture (includes repo announcement and state event with deterministic commit)
236 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
237 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
238
239 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
240
241 // Extract repo_id from state event
242 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
243 .and_then(|t| t.content())
244 .ok_or("Missing repo_id")?
245 .to_string();
246 let npub = state_event.pubkey.to_bech32()
247 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
248
249 // Verify repo exists
250 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
251 if !repo_path.exists() {
252 return Err(format!("Repo not found: {}", repo_path.display()));
253 }
254
255 // Clone repo
256 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
257
258 // Create deterministic commit locally (this will be the root commit with no parent)
259 let commit_hash = create_deterministic_commit(&clone_path, "Initial commit")
260 .map_err(|e| {
261 let _ = fs::remove_dir_all(&clone_path);
262 e
263 })?;
264
265 // Verify commit hash matches expected deterministic hash
266 if commit_hash != DETERMINISTIC_COMMIT_HASH {
267 let _ = fs::remove_dir_all(&clone_path);
268 return Err(format!(
269 "Commit hash mismatch: got {}, expected {}",
270 commit_hash, DETERMINISTIC_COMMIT_HASH
271 ));
272 }
273
274 // Create main branch pointing to our deterministic commit
275 let branch_output = Command::new("git")
276 .args(["branch", "main"])
277 .current_dir(&clone_path)
278 .output()
279 .map_err(|e| {
280 let _ = fs::remove_dir_all(&clone_path);
281 format!("Failed to create main branch: {}", e)
282 })?;
283
284 if !branch_output.status.success() {
285 let _ = fs::remove_dir_all(&clone_path);
286 return Err(format!(
287 "Failed to create main branch: {}",
288 String::from_utf8_lossy(&branch_output.stderr)
289 ));
290 }
291
292 // Checkout main branch
293 let checkout_output = Command::new("git")
294 .args(["checkout", "main"])
295 .current_dir(&clone_path)
296 .output()
297 .map_err(|e| {
298 let _ = fs::remove_dir_all(&clone_path);
299 format!("Failed to checkout main branch: {}", e)
300 })?;
301
302 if !checkout_output.status.success() {
303 let _ = fs::remove_dir_all(&clone_path);
304 return Err(format!(
305 "Failed to checkout main branch: {}",
306 String::from_utf8_lossy(&checkout_output.stderr)
307 ));
308 }
309
310 // Push the commit to the server so the bare repo matches the state event
311 let push_output = Command::new("git")
312 .args(["push", "origin", "main"])
313 .current_dir(&clone_path)
314 .env("GIT_TERMINAL_PROMPT", "0")
315 .output()
316 .map_err(|e| {
317 let _ = fs::remove_dir_all(&clone_path);
318 format!("Failed to push to server: {}", e)
319 })?;
320
321 if !push_output.status.success() {
322 let _ = fs::remove_dir_all(&clone_path);
323 return Err(format!(
324 "Failed to push to server: {}",
325 String::from_utf8_lossy(&push_output.stderr)
326 ));
327 }
328
329 Ok(RepoSetup {
330 clone_path,
331 repo_id,
332 npub,
333 commit_hash,
334 })
335}
336
337/// Helper function to set up a maintainer repository with deterministic commit (state only)
338///
339/// This performs all the common setup steps needed for maintainer push authorization tests:
340/// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit)
341/// 2. Gets MaintainerState fixture (maintainer's state event ONLY - no announcement)
342/// 3. Extracts repo_id and owner npub
343/// 4. Verifies repo exists on disk
344/// 5. Clones the repository using owner's npub
345/// 6. Creates maintainer deterministic commit locally
346/// 7. Verifies commit hash matches expected
347/// 8. Creates and checks out main branch
348/// 9. Pushes the commit so the grasp server has the state in the state event
349///
350/// Note: This does NOT publish a maintainer announcement. For tests that need the
351/// maintainer announcement (like recursive maintainer tests), use setup_repo_for_recursive_maintainer
352/// which publishes MaintainerAnnouncement separately.
353///
354/// Returns RepoSetup which auto-cleans up the clone_path on drop
355pub async fn setup_repo_for_maintainer(
356 client: &AuditClient,
357 git_data_dir: &Path,
358 relay_domain: &str,
359) -> Result<RepoSetup, String> {
360 use crate::MAINTAINER_DETERMINISTIC_COMMIT_HASH;
361
362 let ctx = TestContext::new(client);
363
364 // Get RepoState fixture (includes owner's repo announcement and state event with owner's deterministic commit)
365 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
366 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
367
368 // Get MaintainerState fixture ONLY (no announcement - tests state-only authorization)
369 let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await
370 .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?;
371
372 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
373
374 // Extract repo_id from state event
375 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
376 .and_then(|t| t.content())
377 .ok_or("Missing repo_id")?
378 .to_string();
379
380 // The npub is from the owner keys (the signer of the state event)
381 let npub = state_event.pubkey.to_bech32()
382 .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?;
383
384 // Verify repo exists
385 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
386 if !repo_path.exists() {
387 return Err(format!("Owner repo not found: {}", repo_path.display()));
388 }
389
390 // Clone repo using owner's npub
391 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
392
393 // Create maintainer deterministic commit locally (this will be the root commit with no parent)
394 let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer)
395 .map_err(|e| {
396 let _ = fs::remove_dir_all(&clone_path);
397 e
398 })?;
399
400 // Verify commit hash matches expected maintainer deterministic hash
401 if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH {
402 let _ = fs::remove_dir_all(&clone_path);
403 return Err(format!(
404 "Maintainer commit hash mismatch: got {}, expected {}",
405 commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH
406 ));
407 }
408
409 // Create main branch pointing to our deterministic commit
410 let branch_output = Command::new("git")
411 .args(["branch", "main"])
412 .current_dir(&clone_path)
413 .output()
414 .map_err(|e| {
415 let _ = fs::remove_dir_all(&clone_path);
416 format!("Failed to create main branch: {}", e)
417 })?;
418
419 if !branch_output.status.success() {
420 let _ = fs::remove_dir_all(&clone_path);
421 return Err(format!(
422 "Failed to create main branch: {}",
423 String::from_utf8_lossy(&branch_output.stderr)
424 ));
425 }
426
427 // Checkout main branch
428 let checkout_output = Command::new("git")
429 .args(["checkout", "main"])
430 .current_dir(&clone_path)
431 .output()
432 .map_err(|e| {
433 let _ = fs::remove_dir_all(&clone_path);
434 format!("Failed to checkout main branch: {}", e)
435 })?;
436
437 if !checkout_output.status.success() {
438 let _ = fs::remove_dir_all(&clone_path);
439 return Err(format!(
440 "Failed to checkout main branch: {}",
441 String::from_utf8_lossy(&checkout_output.stderr)
442 ));
443 }
444
445 // Push the commit to the server so the bare repo matches the state event
446 let push_output = Command::new("git")
447 .args(["push", "origin", "main"])
448 .current_dir(&clone_path)
449 .env("GIT_TERMINAL_PROMPT", "0")
450 .output()
451 .map_err(|e| {
452 let _ = fs::remove_dir_all(&clone_path);
453 format!("Failed to push to server: {}", e)
454 })?;
455
456 if !push_output.status.success() {
457 let _ = fs::remove_dir_all(&clone_path);
458 return Err(format!(
459 "Failed to push to server: {}",
460 String::from_utf8_lossy(&push_output.stderr)
461 ));
462 }
463
464 Ok(RepoSetup {
465 clone_path,
466 repo_id,
467 npub,
468 commit_hash,
469 })
470}
471
472/// Helper function to set up a recursive maintainer repository with deterministic commit
473///
474/// This performs all the common setup steps needed for recursive maintainer push authorization tests:
475/// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit)
476/// 2. Gets MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag)
477/// 3. Gets MaintainerState fixture (maintainer's state event)
478/// 4. Gets RecursiveMaintainerRepoAndState fixture (recursive maintainer's repo - completes 3-level chain)
479/// 5. Extracts repo_id and owner npub
480/// 6. Verifies repo exists on disk
481/// 7. Clones the repository using owner's npub
482/// 8. Creates recursive maintainer deterministic commit locally
483/// 9. Verifies commit hash matches expected
484/// 10. Creates and checks out main branch
485/// 11. Pushes the commit so the grasp server has the state in the state event
486///
487/// Returns RepoSetup which auto-cleans up the clone_path on drop
488pub async fn setup_repo_for_recursive_maintainer(
489 client: &AuditClient,
490 git_data_dir: &Path,
491 relay_domain: &str,
492) -> Result<RepoSetup, String> {
493 use crate::RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH;
494
495 let ctx = TestContext::new(client);
496
497 // Get RepoState fixture (includes owner's repo announcement and state event)
498 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
499 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
500
501 // Get MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag)
502 let _maintainer_announcement = ctx.get_fixture(FixtureKind::MaintainerAnnouncement).await
503 .map_err(|e| format!("Failed to create maintainer announcement fixture: {}", e))?;
504
505 // Get MaintainerState fixture (maintainer's state event)
506 let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await
507 .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?;
508
509 // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain)
510 let _recursive_maintainer_state = ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await
511 .map_err(|e| format!("Failed to create recursive maintainer repo state fixture: {}", e))?;
512
513 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
514
515 // Extract repo_id from owner's state event
516 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
517 .and_then(|t| t.content())
518 .ok_or("Missing repo_id")?
519 .to_string();
520
521 // The npub is from the owner keys (the signer of the state event)
522 let npub = state_event.pubkey.to_bech32()
523 .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?;
524
525 // Verify repo exists
526 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
527 if !repo_path.exists() {
528 return Err(format!("Owner repo not found: {}", repo_path.display()));
529 }
530
531 // Clone repo using owner's npub
532 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
533
534 // Create recursive maintainer deterministic commit locally (this will be the root commit with no parent)
535 let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer)
536 .map_err(|e| {
537 let _ = fs::remove_dir_all(&clone_path);
538 e
539 })?;
540
541 // Verify commit hash matches expected recursive maintainer deterministic hash
542 if commit_hash != RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH {
543 let _ = fs::remove_dir_all(&clone_path);
544 return Err(format!(
545 "Recursive maintainer commit hash mismatch: got {}, expected {}",
546 commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
547 ));
548 }
549
550 // Create main branch pointing to our deterministic commit
551 let branch_output = Command::new("git")
552 .args(["branch", "main"])
553 .current_dir(&clone_path)
554 .output()
555 .map_err(|e| {
556 let _ = fs::remove_dir_all(&clone_path);
557 format!("Failed to create main branch: {}", e)
558 })?;
559
560 if !branch_output.status.success() {
561 let _ = fs::remove_dir_all(&clone_path);
562 return Err(format!(
563 "Failed to create main branch: {}",
564 String::from_utf8_lossy(&branch_output.stderr)
565 ));
566 }
567
568 // Checkout main branch
569 let checkout_output = Command::new("git")
570 .args(["checkout", "main"])
571 .current_dir(&clone_path)
572 .output()
573 .map_err(|e| {
574 let _ = fs::remove_dir_all(&clone_path);
575 format!("Failed to checkout main branch: {}", e)
576 })?;
577
578 if !checkout_output.status.success() {
579 let _ = fs::remove_dir_all(&clone_path);
580 return Err(format!(
581 "Failed to checkout main branch: {}",
582 String::from_utf8_lossy(&checkout_output.stderr)
583 ));
584 }
585
586 // Push the commit to the server so the bare repo matches the state event
587 let push_output = Command::new("git")
588 .args(["push", "origin", "main"])
589 .current_dir(&clone_path)
590 .env("GIT_TERMINAL_PROMPT", "0")
591 .output()
592 .map_err(|e| {
593 let _ = fs::remove_dir_all(&clone_path);
594 format!("Failed to push to server: {}", e)
595 })?;
596
597 if !push_output.status.success() {
598 let _ = fs::remove_dir_all(&clone_path);
599 return Err(format!(
600 "Failed to push to server: {}",
601 String::from_utf8_lossy(&push_output.stderr)
602 ));
603 }
604
605 Ok(RepoSetup {
606 clone_path,
607 repo_id,
608 npub,
609 commit_hash,
610 })
611}
612
613/// Helper to attempt a push and return success/failure
614fn try_push(clone_path: &Path) -> Result<bool, String> {
615 let output = Command::new("git")
616 .args(["push", "origin", "main"])
617 .current_dir(clone_path)
618 .env("GIT_TERMINAL_PROMPT", "0")
619 .output()
620 .map_err(|e| format!("Failed to execute git push: {}", e))?;
621
622 Ok(output.status.success())
623}
624
625impl PushAuthorizationTests { 31impl PushAuthorizationTests {
626 /// Test that push is authorized when state event matches the commit 32 /// Test that push is authorized when state event matches the commit
627 /// 33 ///