From d7799d452cfe2627a68fcaab25377b4d5c9ede15 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 15 May 2026 16:41:49 +0000 Subject: [PATCH] test(grasp06): assert relaxation-accepted PR event stays in purgatory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PR event accepted under the GRASP-06 relaxation (un-announced coord, clone tag names this relay's /prs/ endpoint) must land in purgatory and not be broadcast until the matching refs/nostr/ push arrives. Spec: 06.md lines 21–24 in combination with GRASP-01 line 22. The existing acceptance test test_pr_event_accepted_when_clone_tag_names_prs_endpoint asserts the relay returned OK but discards the served-vs-purgatory bit returned by send_event_and_note_purgatory. A future regression that short-circuited the relaxation into WritePolicyResult::Accept would still pass that test while quietly leaking orphan PR events before their git data exists anywhere on this relay. The new test captures the in_purgatory bool and asserts it. Pattern mirrors GRASP-01's test_pr_event_accepted_into_purgatory_and_isnt_served. Shares the same SpecRef (Grasp06RelaxAcceptPrEvent) — the report renderer groups multiple tests per requirement, so this becomes a second check under the same spec line. --- .../src/specs/grasp06/event_acceptance.rs | 118 ++++++++++++++++++ grasp-audit/src/specs/grasp06/mod.rs | 15 +++ tests/grasp06_pr_hosting.rs | 27 ++++ 3 files changed, 160 insertions(+) diff --git a/grasp-audit/src/specs/grasp06/event_acceptance.rs b/grasp-audit/src/specs/grasp06/event_acceptance.rs index 8804291..a8f924f 100644 --- a/grasp-audit/src/specs/grasp06/event_acceptance.rs +++ b/grasp-audit/src/specs/grasp06/event_acceptance.rs @@ -135,6 +135,124 @@ impl EventAcceptanceTests { .await } + /// Test: an orphan PR event accepted via the GRASP-06 relaxation MUST be + /// placed in purgatory and MUST NOT be broadcast until matching git data + /// arrives. + /// + /// Spec: 06.md lines 21–24 say "MUST accept" — accept here means "accept + /// as the standard PR pipeline would". GRASP-01 line 22 (the purgatory + /// rule) then governs visibility: an accepted PR event with no matching + /// git data is held in purgatory, not served, until either the matching + /// `refs/nostr/` push arrives or the 30-minute TTL expires. + /// + /// ## Why this test + /// + /// `test_pr_event_accepted_when_clone_tag_names_prs_endpoint` already + /// pins the acceptance contract but discards the purgatory bit returned + /// by `send_event_and_note_purgatory`. That leaves a gap: a future bug + /// that accidentally short-circuits the relaxation into + /// `WritePolicyResult::Accept` (immediate serve) would still pass the + /// "accept" test but quietly violate the purgatory contract — leaking + /// the orphan PR event before its commit object exists anywhere on + /// this relay. This test closes that gap. + /// + /// Mirrors the pattern of GRASP-01's + /// `test_pr_event_accepted_into_purgatory_and_isnt_served`: send, + /// short wait, query by id and assert empty. + /// + /// ## TDD posture + /// + /// Pre-implementation this FAILS by rejection (test 5's failure path). + /// Once the relaxation lands correctly it turns green and stays green + /// as long as the relaxation routes through purgatory rather than + /// short-circuiting to accept. + pub async fn test_pr_event_accepted_via_relaxation_is_held_in_purgatory( + client: &AuditClient, + ) -> TestResult { + TestResult::new( + "pr_event_accepted_via_relaxation_is_held_in_purgatory", + SpecRef::Grasp06RelaxAcceptPrEvent, + "PR event accepted under the GRASP-06 relaxation MUST be held in purgatory \ + until matching git data arrives", + ) + .run(|| async { + // Setup mirrors test 5 exactly — same shape of orphan PR event + // for the same reasons. The only behavioural difference is that + // we additionally assert non-broadcast after acceptance. + let ws_url = client + .relay_url() + .await + .map_err(|e| format!("Failed to get relay URL: {}", e))?; + let http_url = AuditClient::ws_to_http_url(&ws_url) + .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?; + + let target_pubkey_hex = Keys::generate().public_key().to_hex(); + let identifier = format!("audit-grasp06-{}", uuid::Uuid::new_v4()); + let a_tag_value = format!("30617:{}:{}", target_pubkey_hex, identifier); + + let pr_author_npub = client + .pr_author_keys() + .public_key() + .to_bech32() + .map_err(|e| format!("Failed to bech32-encode pr_author npub: {}", e))?; + let clone_url = format!( + "{}/prs/{}/{}.git", + http_url.trim_end_matches('/'), + pr_author_npub, + identifier + ); + + let commit_hex = Keys::generate().public_key().to_hex(); + + let event = client + .event_builder( + Kind::GitPullRequest, + "grasp-06 audit: orphan PR must land in purgatory, not be served", + ) + .tag(Tag::custom(TagKind::custom("a"), vec![a_tag_value])) + .tag(Tag::custom(TagKind::custom("c"), vec![commit_hex])) + .tag(Tag::custom(TagKind::custom("clone"), vec![clone_url])) + .build(client.pr_author_keys()) + .map_err(|e| format!("Failed to build PR event: {}", e))?; + let event_id = event.id; + + // Acceptance is a precondition for this test, not the thing + // being asserted — that's test 5's job. If the relay rejects + // here, this test cannot make its assertion; surface the cause + // rather than silently misreporting. + let (_, in_purgatory) = + client + .send_event_and_note_purgatory(event) + .await + .map_err(|e| { + format!( + "Relay rejected the PR event during test setup (test 5 \ + covers the acceptance contract; this test assumes it \ + passes). Relay error: {}", + e + ) + })?; + + // `send_event_and_note_purgatory` already does a 300ms wait and a + // single `is_event_on_relay` probe; trust its result rather than + // re-probing. + if !in_purgatory { + return Err(format!( + "Orphan PR event {} accepted via GRASP-06 relaxation was \ + served immediately. The relaxation must route un-announced \ + PRs through purgatory (06.md lines 21–24 + GRASP-01 line 22), \ + not directly to accept. A common cause is a \ + `WritePolicyResult::Accept` short-circuit in the relaxation \ + branch.", + event_id + )); + } + + Ok(()) + }) + .await + } + /// Test: a PR event for a coord this relay has no announcement for, with /// a `clone` tag pointing somewhere OTHER than this relay's `/prs/` /// endpoint, MUST remain rejected. diff --git a/grasp-audit/src/specs/grasp06/mod.rs b/grasp-audit/src/specs/grasp06/mod.rs index d400673..c952c61 100644 --- a/grasp-audit/src/specs/grasp06/mod.rs +++ b/grasp-audit/src/specs/grasp06/mod.rs @@ -107,6 +107,12 @@ impl Grasp06Tests { ) .await, ); + results.add( + EventAcceptanceTests::test_pr_event_accepted_via_relaxation_is_held_in_purgatory( + client, + ) + .await, + ); results.add( EventAcceptanceTests::test_pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint( client, @@ -174,6 +180,15 @@ impl Grasp06Tests { ) .skip(reason), ); + results.add( + TestResult::new( + "pr_event_accepted_via_relaxation_is_held_in_purgatory", + SpecRef::Grasp06RelaxAcceptPrEvent, + "PR event accepted under the GRASP-06 relaxation MUST be held in purgatory \ + until matching git data arrives", + ) + .skip(reason), + ); results.add( TestResult::new( "pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint", diff --git a/tests/grasp06_pr_hosting.rs b/tests/grasp06_pr_hosting.rs index 17482c2..ae3c5ef 100644 --- a/tests/grasp06_pr_hosting.rs +++ b/tests/grasp06_pr_hosting.rs @@ -211,6 +211,33 @@ isolated_test_with_grasp_06!( EventAcceptanceTests::test_pr_event_accepted_when_clone_tag_names_prs_endpoint ); +// ============================================================================= +// Test 6b: Relaxation-accepted PR MUST stay in purgatory until git data arrives +// ============================================================================= +// +// Spec: GRASP-06 06.md lines 21–24, in combination with GRASP-01 line 22 +// (the purgatory rule). +// +// Contract: a PR event accepted under the GRASP-06 relaxation (un-announced +// coord, clone tag names this relay's /prs/ endpoint) MUST be held in +// purgatory and MUST NOT be broadcast until the matching `refs/nostr/` +// push arrives. The acceptance message must be `OK true "purgatory: ..."`, +// not `OK true` for an immediately served event. +// +// Why a separate test from 6: test 6 only asserts the relay returned OK, +// discarding the served-vs-purgatory bit. A future regression that +// short-circuited the relaxation to `WritePolicyResult::Accept` would still +// pass test 6 but quietly leak orphan PR events without their git data. +// This test guards that boundary. +// +// Wired only as `with_grasp_06`. Once the relaxation is in place this is +// green and stays green as long as the relaxation routes through purgatory. + +isolated_test_with_grasp_06!( + test_pr_event_accepted_via_relaxation_is_held_in_purgatory_with_grasp_06, + EventAcceptanceTests::test_pr_event_accepted_via_relaxation_is_held_in_purgatory +); + // ============================================================================= // Test 7: Relaxation MUST NOT apply when clone tag does not name this relay // =============================================================================