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:
Diffstat (limited to 'grasp-audit/src')
-rw-r--r--grasp-audit/src/specs/grasp01/purgatory.rs329
1 files changed, 216 insertions, 113 deletions
diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs
index 9d97d3b..29eabad 100644
--- a/grasp-audit/src/specs/grasp01/purgatory.rs
+++ b/grasp-audit/src/specs/grasp01/purgatory.rs
@@ -27,9 +27,11 @@
27//! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds 27//! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds
28//! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data 28//! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data
29 29
30use crate::fixtures::{clone_repo, create_commit, try_push};
30use crate::specs::grasp01::SpecRef; 31use crate::specs::grasp01::SpecRef;
31use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; 32use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
32use nostr_sdk::prelude::*; 33use nostr_sdk::prelude::*;
34use std::fs;
33use std::time::Duration; 35use std::time::Duration;
34 36
35/// Test suite for GRASP-01 purgatory behavior 37/// Test suite for GRASP-01 purgatory behavior
@@ -47,9 +49,9 @@ impl PurgatoryTests {
47 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); 49 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
48 50
49 // Deletion event tests (NIP-09) 51 // Deletion event tests (NIP-09)
50 results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await); 52 results.add(Self::test_deletion_by_event_id_removes_purgatory_state_event(client).await);
51 results.add( 53 results.add(
52 Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await, 54 Self::test_deletion_by_coordinate_removes_purgatory_state_event(client).await,
53 ); 55 );
54 56
55 // State event purgatory tests (already implemented) 57 // State event purgatory tests (already implemented)
@@ -656,192 +658,293 @@ impl PurgatoryTests {
656 // Deletion Event Tests (NIP-09) 658 // Deletion Event Tests (NIP-09)
657 // ============================================================ 659 // ============================================================
658 660
659 /// Test: Kind 5 deletion event by event ID removes purgatory announcement 661 /// Test: Kind 5 deletion event by event ID removes a purgatory state event
660 /// 662 ///
661 /// Spec: NIP-09 663 /// Spec: NIP-09
662 /// "A special event with kind 5... having a list of one or more `e` or `a` tags, 664 /// "A special event with kind 5... having a list of one or more `e` or `a` tags,
663 /// each referencing an event the author is requesting to be deleted." 665 /// each referencing an event the author is requesting to be deleted."
664 /// 666 ///
665 /// This test verifies: 667 /// This test verifies:
666 /// 1. Send a valid repository announcement (enters purgatory) 668 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
667 /// 2. Send a kind 5 deletion event referencing the announcement by event ID 669 /// 2. Clone the repo and create a unique commit (not yet pushed)
668 /// 3. The announcement is no longer in purgatory (git push would fail) 670 /// 3. Submit a state event pointing to that unique commit (enters purgatory)
669 /// 4. The deletion event itself is accepted by the relay 671 /// 4. Send a kind 5 deletion event referencing the state event by event ID
670 pub async fn test_deletion_by_event_id_removes_purgatory_announcement( 672 /// 5. Attempt to push the unique commit — MUST be rejected (no authorized state event)
673 pub async fn test_deletion_by_event_id_removes_purgatory_state_event(
671 client: &AuditClient, 674 client: &AuditClient,
672 ) -> TestResult { 675 ) -> TestResult {
673 TestResult::new( 676 TestResult::new(
674 "deletion_by_event_id_removes_purgatory_announcement", 677 "deletion_by_event_id_removes_purgatory_state_event",
675 SpecRef::PurgatoryAcceptUntilGitData, 678 SpecRef::PurgatoryAcceptUntilGitData,
676 "Kind 5 deletion by event ID SHOULD remove a purgatory announcement", 679 "Kind 5 deletion by event ID SHOULD remove a purgatory state event, causing push rejection",
677 ) 680 )
678 .run(|| async { 681 .run(|| async {
679 let ctx = TestContext::new(client); 682 let ctx = TestContext::new(client);
680 683
681 // Send announcement to purgatory 684 // Stage 1: get a promoted repo with git data already on the relay
682 let repo = ctx 685 let existing_state = ctx
683 .get_fixture(FixtureKind::ValidRepoSent) 686 .get_fixture(FixtureKind::OwnerStateDataPushed)
684 .await 687 .await
685 .map_err(|e| format!("Failed to create repo announcement: {}", e))?; 688 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
686 689
687 let repo_id = repo 690 let repo_id = existing_state
688 .tags 691 .tags
689 .iter() 692 .iter()
690 .find(|t| t.kind() == TagKind::d()) 693 .find(|t| t.kind() == TagKind::d())
691 .and_then(|t| t.content()) 694 .and_then(|t| t.content())
692 .ok_or("Missing d tag in repo announcement")? 695 .ok_or("Missing d tag in state event")?
693 .to_string(); 696 .to_string();
694 697
695 // Verify it's in purgatory (not served) 698 let relay_domain = client
696 tokio::time::sleep(Duration::from_millis(300)).await; 699 .relay_url()
697 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { 700 .await
698 return Err( 701 .map_err(|e| e.to_string())?
699 "Announcement was served immediately - purgatory not working".to_string(), 702 .trim_start_matches("ws://")
700 ); 703 .trim_start_matches("wss://")
704 .to_string();
705
706 let npub = client
707 .public_key()
708 .to_bech32()
709 .map_err(|e| e.to_string())?;
710
711 // Stage 2: clone the repo and create a unique commit (not pushed yet)
712 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
713 .map_err(|e| format!("Failed to clone repo: {}", e))?;
714
715 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
716
717 let unique_commit = match create_commit(&clone_path, "deletion test unique commit") {
718 Ok(h) => h,
719 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
720 };
721
722 // Stage 3: submit a state event pointing to the unique commit (enters purgatory)
723 let state_event = client
724 .event_builder(Kind::RepoState, "")
725 .tag(Tag::identifier(&repo_id))
726 .tag(Tag::custom(
727 TagKind::custom("refs/heads/main"),
728 vec![unique_commit.clone()],
729 ))
730 .tag(Tag::custom(
731 TagKind::custom("HEAD"),
732 vec!["ref: refs/heads/main".to_string()],
733 ))
734 .build(client.keys())
735 .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
736
737 let (_, in_purgatory) = client
738 .send_event_and_note_purgatory(state_event.clone())
739 .await
740 .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
741
742 if !in_purgatory {
743 cleanup();
744 return Err(format!(
745 "State event was served immediately (not in purgatory). \
746 Commit {} may already exist on relay.",
747 unique_commit
748 ));
701 } 749 }
702 750
703 // Build and send kind 5 deletion event referencing the announcement by event ID 751 // Stage 4: send kind 5 deletion event referencing the state event by event ID
704 let deletion = client 752 let deletion = client
705 .event_builder(Kind::EventDeletion, "") 753 .event_builder(Kind::EventDeletion, "")
706 .tag(Tag::event(repo.id)) 754 .tag(Tag::event(state_event.id))
707 .tag(Tag::custom( 755 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
708 TagKind::custom("k"),
709 vec!["30617"],
710 ))
711 .build(client.keys()) 756 .build(client.keys())
712 .map_err(|e| format!("Failed to build deletion event: {}", e))?; 757 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
713 758
714 client 759 client
715 .send_event(deletion) 760 .send_event(deletion)
716 .await 761 .await
717 .map_err(|e| format!("Relay rejected deletion event: {}", e))?; 762 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
718 763
719 tokio::time::sleep(Duration::from_millis(300)).await; 764 tokio::time::sleep(Duration::from_millis(300)).await;
720 765
721 // Verify the announcement can no longer be promoted by attempting a git push. 766 // Stage 5: attempt to push the unique commit — must be rejected
722 // We check this indirectly: if the purgatory entry was removed, a subsequent 767 let push_result = try_push(&clone_path);
723 // git push to the repo path should fail (no bare repo). 768 cleanup();
724 // For the integration test we verify the announcement is still not served 769
725 // (it was never promoted) and that the deletion event was accepted. 770 match push_result {
726 // The bare-repo deletion is verified by attempting a git clone. 771 Ok(false) => Ok(()), // push rejected as expected
727 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) 772 Ok(true) => Err(format!(
728 .map_err(|e| e.to_string())?; 773 "Push was accepted but should have been rejected. \
729 let clone_url = format!( 774 The state event (id={}) was deleted, so commit {} \
730 "{}/{}/{}.git", 775 should not be authorized.",
731 http_url, 776 state_event.id, unique_commit
732 client.public_key().to_bech32().map_err(|e| e.to_string())?, 777 )),
733 repo_id 778 Err(e) => Err(format!("Git push error: {}", e)),
734 );
735
736 // git ls-remote should fail (bare repo deleted)
737 let output = std::process::Command::new("git")
738 .args(["ls-remote", &clone_url])
739 .output()
740 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
741
742 if output.status.success() {
743 return Err(format!(
744 "Bare repo still exists after deletion event. \
745 Expected git ls-remote to fail for {}",
746 clone_url
747 ));
748 } 779 }
749
750 Ok(())
751 }) 780 })
752 .await 781 .await
753 } 782 }
754 783
755 /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement 784 /// Test: Kind 5 deletion event by `a` tag coordinate removes a purgatory state event
756 /// 785 ///
757 /// Spec: NIP-09 786 /// Spec: NIP-09
758 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable 787 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable
759 /// event up to the `created_at` timestamp of the deletion request event." 788 /// event up to the `created_at` timestamp of the deletion request event."
760 /// 789 ///
761 /// This test verifies: 790 /// This test verifies:
762 /// 1. Send a valid repository announcement (enters purgatory) 791 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
763 /// 2. Send a kind 5 deletion event referencing the announcement by coordinate 792 /// 2. Generate a fresh keypair for a new maintainer
764 /// (`30617:<pubkey>:<identifier>`) 793 /// 3. Send a replacement owner announcement adding the new maintainer (goes to DB)
765 /// 3. The announcement is no longer in purgatory 794 /// 4. Send a state event signed by the new maintainer pointing to a unique commit
766 pub async fn test_deletion_by_coordinate_removes_purgatory_announcement( 795 /// (enters purgatory — maintainer is authorized but commit doesn't exist yet)
796 /// 5. Delete by coordinate `30618:<new_maintainer_pubkey>:<identifier>`
797 /// 6. Clone repo, create that unique commit, attempt to push — MUST be rejected
798 /// (the state event was deleted, so the commit is no longer authorized)
799 pub async fn test_deletion_by_coordinate_removes_purgatory_state_event(
767 client: &AuditClient, 800 client: &AuditClient,
768 ) -> TestResult { 801 ) -> TestResult {
769 TestResult::new( 802 TestResult::new(
770 "deletion_by_coordinate_removes_purgatory_announcement", 803 "deletion_by_coordinate_removes_purgatory_state_event",
771 SpecRef::PurgatoryAcceptUntilGitData, 804 SpecRef::PurgatoryAcceptUntilGitData,
772 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement", 805 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory state event, causing push rejection",
773 ) 806 )
774 .run(|| async { 807 .run(|| async {
775 let ctx = TestContext::new(client); 808 let ctx = TestContext::new(client);
776 809
777 // Send announcement to purgatory 810 // Stage 1: get a promoted repo with git data already on the relay
778 let repo = ctx 811 let existing_state = ctx
779 .get_fixture(FixtureKind::ValidRepoSent) 812 .get_fixture(FixtureKind::OwnerStateDataPushed)
780 .await 813 .await
781 .map_err(|e| format!("Failed to create repo announcement: {}", e))?; 814 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
782 815
783 let repo_id = repo 816 let repo_id = existing_state
784 .tags 817 .tags
785 .iter() 818 .iter()
786 .find(|t| t.kind() == TagKind::d()) 819 .find(|t| t.kind() == TagKind::d())
787 .and_then(|t| t.content()) 820 .and_then(|t| t.content())
788 .ok_or("Missing d tag in repo announcement")? 821 .ok_or("Missing d tag in state event")?
789 .to_string(); 822 .to_string();
790 823
791 // Verify it's in purgatory (not served) 824 // Stage 2: generate a fresh keypair for a new maintainer
792 tokio::time::sleep(Duration::from_millis(300)).await; 825 let new_maintainer_keys = Keys::generate();
793 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { 826 let new_maintainer_hex = new_maintainer_keys.public_key().to_hex();
794 return Err(
795 "Announcement was served immediately - purgatory not working".to_string(),
796 );
797 }
798 827
799 // Build coordinate: `30617:<pubkey_hex>:<identifier>` 828 // Stage 3: send a replacement owner announcement that adds the new maintainer.
800 let coord = format!( 829 // This is a replacement (same pubkey + identifier already in DB) so it goes
801 "30617:{}:{}", 830 // straight to the database without entering purgatory.
802 client.public_key().to_hex(), 831 let relay_url = client
803 repo_id 832 .relay_url()
804 ); 833 .await
834 .map_err(|e| e.to_string())?;
835 let http_url = relay_url
836 .replace("ws://", "http://")
837 .replace("wss://", "https://");
838 let npub = client
839 .public_key()
840 .to_bech32()
841 .map_err(|e| e.to_string())?;
805 842
806 // Build and send kind 5 deletion event referencing by coordinate 843 let replacement_announcement = client
807 let deletion = client 844 .event_builder(Kind::GitRepoAnnouncement, "")
808 .event_builder(Kind::EventDeletion, "") 845 .tag(Tag::identifier(&repo_id))
809 .tag(Tag::custom(TagKind::custom("a"), vec![coord])) 846 .tag(Tag::custom(
810 .tag(Tag::custom(TagKind::custom("k"), vec!["30617"])) 847 TagKind::custom("clone"),
848 vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
849 ))
850 .tag(Tag::custom(
851 TagKind::custom("relays"),
852 vec![relay_url.clone()],
853 ))
854 .tag(Tag::custom(
855 TagKind::custom("maintainers"),
856 vec![new_maintainer_hex.clone()],
857 ))
811 .build(client.keys()) 858 .build(client.keys())
812 .map_err(|e| format!("Failed to build deletion event: {}", e))?; 859 .map_err(|e| format!("Failed to build replacement announcement: {}", e))?;
813 860
814 client 861 client
815 .send_event(deletion) 862 .send_event(replacement_announcement)
816 .await 863 .await
817 .map_err(|e| format!("Relay rejected deletion event: {}", e))?; 864 .map_err(|e| format!("Relay rejected replacement announcement: {}", e))?;
818 865
819 tokio::time::sleep(Duration::from_millis(300)).await; 866 tokio::time::sleep(Duration::from_millis(200)).await;
820 867
821 // Verify bare repo was deleted 868 // Stage 4: clone the repo and create a unique commit (not pushed yet)
822 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) 869 let relay_domain = relay_url
823 .map_err(|e| e.to_string())?; 870 .trim_start_matches("ws://")
824 let clone_url = format!( 871 .trim_start_matches("wss://")
825 "{}/{}/{}.git", 872 .to_string();
826 http_url, 873
827 client.public_key().to_bech32().map_err(|e| e.to_string())?, 874 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
828 repo_id 875 .map_err(|e| format!("Failed to clone repo: {}", e))?;
829 ); 876
877 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
830 878
831 let output = std::process::Command::new("git") 879 let unique_commit = match create_commit(&clone_path, "deletion coordinate test unique commit") {
832 .args(["ls-remote", &clone_url]) 880 Ok(h) => h,
833 .output() 881 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
834 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; 882 };
835 883
836 if output.status.success() { 884 // Stage 5: submit a state event signed by the new maintainer pointing to the
885 // unique commit. The new maintainer is now authorized (listed in the replacement
886 // announcement), so the state event should enter purgatory (commit doesn't exist).
887 let state_event = client
888 .event_builder(Kind::RepoState, "")
889 .tag(Tag::identifier(&repo_id))
890 .tag(Tag::custom(
891 TagKind::custom("refs/heads/main"),
892 vec![unique_commit.clone()],
893 ))
894 .tag(Tag::custom(
895 TagKind::custom("HEAD"),
896 vec!["ref: refs/heads/main".to_string()],
897 ))
898 .build(&new_maintainer_keys)
899 .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
900
901 let (_, in_purgatory) = client
902 .send_event_and_note_purgatory(state_event.clone())
903 .await
904 .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
905
906 if !in_purgatory {
907 cleanup();
837 return Err(format!( 908 return Err(format!(
838 "Bare repo still exists after deletion event. \ 909 "State event was served immediately (not in purgatory). \
839 Expected git ls-remote to fail for {}", 910 Commit {} may already exist on relay.",
840 clone_url 911 unique_commit
841 )); 912 ));
842 } 913 }
843 914
844 Ok(()) 915 // Stage 6: send kind 5 deletion event signed by the new maintainer,
916 // referencing their state event by coordinate `30618:<pubkey>:<identifier>`
917 let coord = format!("30618:{}:{}", new_maintainer_hex, repo_id);
918
919 let deletion = client
920 .event_builder(Kind::EventDeletion, "")
921 .tag(Tag::custom(TagKind::custom("a"), vec![coord]))
922 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
923 .build(&new_maintainer_keys)
924 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
925
926 client
927 .send_event(deletion)
928 .await
929 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
930
931 tokio::time::sleep(Duration::from_millis(300)).await;
932
933 // Stage 7: attempt to push the unique commit — must be rejected because
934 // the new maintainer's state event was deleted from purgatory
935 let push_result = try_push(&clone_path);
936 cleanup();
937
938 match push_result {
939 Ok(false) => Ok(()), // push rejected as expected
940 Ok(true) => Err(format!(
941 "Push was accepted but should have been rejected. \
942 The new maintainer's state event (id={}) was deleted by coordinate, \
943 so commit {} should not be authorized.",
944 state_event.id, unique_commit
945 )),
946 Err(e) => Err(format!("Git push error: {}", e)),
947 }
845 }) 948 })
846 .await 949 .await
847 } 950 }