diff options
Diffstat (limited to 'grasp-audit')
| -rw-r--r-- | grasp-audit/src/fixtures.rs | 217 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/push_authorization.rs | 194 |
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 | ||