upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit
diff options
context:
space:
mode:
Diffstat (limited to 'grasp-audit')
-rw-r--r--grasp-audit/src/fixtures.rs217
-rw-r--r--grasp-audit/src/specs/grasp01/push_authorization.rs194
2 files changed, 231 insertions, 180 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
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs
index cd422d2..1e28f8c 100644
--- a/grasp-audit/src/specs/grasp01/push_authorization.rs
+++ b/grasp-audit/src/specs/grasp01/push_authorization.rs
@@ -587,197 +587,31 @@ impl PushAuthorizationTests {
587 /// GRASP-01: "MUST accept pushes via this service that match the latest 587 /// GRASP-01: "MUST accept pushes via this service that match the latest
588 /// repo state announcement on the relay" 588 /// repo state announcement on the relay"
589 /// 589 ///
590 /// ## Fixture-First Pattern 590 /// This test uses the OwnerStateDataPushed fixture which handles all 4 stages:
591 /// 1. **Generated**: Creates RepoState (repo announcement + state event)
592 /// 2. **Sent**: Sends events to relay
593 /// 3. **Verified**: Confirms events accepted by relay
594 /// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay
591 /// 595 ///
592 /// 1. **Generate**: Create TestContext and get RepoState fixture 596 /// The test wraps the fixture result in pass/fail using the error message.
593 /// (repo announcement + state event pointing to deterministic commit) 597 #[allow(unused_variables)] // relay_domain is now handled by fixture
594 /// 2. **Send**: Clone repo, create deterministic commit locally, push to relay
595 /// 3. **Verify**: Push should succeed because state event authorizes this commit
596 pub async fn test_push_authorized_by_owner_state( 598 pub async fn test_push_authorized_by_owner_state(
597 client: &AuditClient, 599 client: &AuditClient,
598 relay_domain: &str, 600 relay_domain: &str,
599 ) -> TestResult { 601 ) -> TestResult {
600 use std::process::Command;
601
602 let test_name = "test_push_authorized_by_owner_state"; 602 let test_name = "test_push_authorized_by_owner_state";
603
604 // ============================================================
605 // Step 1: GENERATE - Create TestContext and get RepoState fixture
606 // ============================================================
607 let ctx = TestContext::new(client); 603 let ctx = TestContext::new(client);
608 604
609 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { 605 // The OwnerStateDataPushed fixture handles all stages:
610 Ok(e) => e, 606 // Generate → Send → Verify → DataPush
611 Err(e) => { 607 match ctx.get_fixture(FixtureKind::OwnerStateDataPushed).await {
612 return TestResult::new( 608 Ok(_state_event) => {
613 test_name,
614 "GRASP-01",
615 "Push authorized with matching state",
616 )
617 .fail(format!("Failed to create RepoState fixture: {}", e));
618 }
619 };
620
621 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
622
623 // Extract repo_id and npub from state event
624 let repo_id = match state_event
625 .tags
626 .iter()
627 .find(|t| t.kind() == TagKind::d())
628 .and_then(|t| t.content())
629 {
630 Some(id) => id.to_string(),
631 None => {
632 return TestResult::new(
633 test_name,
634 "GRASP-01",
635 "Push authorized with matching state",
636 )
637 .fail("Missing repo_id in state event");
638 }
639 };
640
641 let npub = match state_event.pubkey.to_bech32() {
642 Ok(n) => n,
643 Err(e) => {
644 return TestResult::new(
645 test_name,
646 "GRASP-01",
647 "Push authorized with matching state",
648 )
649 .fail(format!("Failed to convert pubkey to bech32: {}", e));
650 }
651 };
652
653 // ============================================================
654 // Step 2: SEND - Clone repo, create deterministic commit, push
655 // ============================================================
656 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
657 Ok(p) => p,
658 Err(e) => {
659 return TestResult::new(
660 test_name,
661 "GRASP-01",
662 "Push authorized with matching state",
663 )
664 .fail(format!("Failed to clone repo: {}", e));
665 }
666 };
667
668 // Cleanup helper
669 let cleanup = || {
670 let _ = fs::remove_dir_all(&clone_path);
671 };
672
673 // Create deterministic commit locally
674 let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") {
675 Ok(h) => h,
676 Err(e) => {
677 cleanup();
678 return TestResult::new(
679 test_name,
680 "GRASP-01",
681 "Push authorized with matching state",
682 )
683 .fail(format!("Failed to create deterministic commit: {}", e));
684 }
685 };
686
687 // Verify commit hash matches expected
688 if commit_hash != DETERMINISTIC_COMMIT_HASH {
689 cleanup();
690 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state")
691 .fail(format!(
692 "Commit hash mismatch: got {}, expected {}",
693 commit_hash, DETERMINISTIC_COMMIT_HASH
694 ));
695 }
696
697 // Create main branch pointing to our deterministic commit
698 let branch_output = Command::new("git")
699 .args(["branch", "main"])
700 .current_dir(&clone_path)
701 .output();
702
703 match branch_output {
704 Err(e) => {
705 cleanup();
706 return TestResult::new(
707 test_name,
708 "GRASP-01",
709 "Push authorized with matching state",
710 )
711 .fail(format!("Failed to create main branch: {}", e));
712 }
713 Ok(output) if !output.status.success() => {
714 cleanup();
715 return TestResult::new(
716 test_name,
717 "GRASP-01",
718 "Push authorized with matching state",
719 )
720 .fail(format!(
721 "Failed to create main branch: {}",
722 String::from_utf8_lossy(&output.stderr)
723 ));
724 }
725 _ => {}
726 }
727
728 // Checkout main branch
729 let checkout_output = Command::new("git")
730 .args(["checkout", "main"])
731 .current_dir(&clone_path)
732 .output();
733
734 match checkout_output {
735 Err(e) => {
736 cleanup();
737 return TestResult::new(
738 test_name,
739 "GRASP-01",
740 "Push authorized with matching state",
741 )
742 .fail(format!("Failed to checkout main branch: {}", e));
743 }
744 Ok(output) if !output.status.success() => {
745 cleanup();
746 return TestResult::new(
747 test_name,
748 "GRASP-01",
749 "Push authorized with matching state",
750 )
751 .fail(format!(
752 "Failed to checkout main branch: {}",
753 String::from_utf8_lossy(&output.stderr)
754 ));
755 }
756 _ => {}
757 }
758
759 // ============================================================
760 // Step 3: VERIFY - Push should succeed because state event
761 // authorizes this commit
762 // ============================================================
763 let push_result = try_push(&clone_path);
764 cleanup();
765
766 match push_result {
767 Ok(true) => {
768 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").pass() 609 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").pass()
769 } 610 }
770 Ok(false) => { 611 Err(e) => {
771 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").fail( 612 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state")
772 format!( 613 .fail(format!("{}", e))
773 "Push was rejected but should have been accepted. \
774 The state event points to commit {} which matches the pushed commit.",
775 DETERMINISTIC_COMMIT_HASH
776 ),
777 )
778 } 614 }
779 Err(e) => TestResult::new(test_name, "GRASP-01", "Push authorized with matching state")
780 .fail(format!("Push error: {}", e)),
781 } 615 }
782 } 616 }
783 617