upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src/specs/grasp01/purgatory.rs
diff options
context:
space:
mode:
Diffstat (limited to 'grasp-audit/src/specs/grasp01/purgatory.rs')
-rw-r--r--grasp-audit/src/specs/grasp01/purgatory.rs983
1 files changed, 983 insertions, 0 deletions
diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs
new file mode 100644
index 0000000..29eabad
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/purgatory.rs
@@ -0,0 +1,983 @@
1//! GRASP-01 Purgatory Tests
2//!
3//! Tests for the GRASP-01 purgatory mechanism where events are accepted but not
4//! served until corresponding git data arrives.
5//!
6//! ## Purgatory Behavior (GRASP-01 Line 22)
7//!
8//! "New repository announcements, repo state announcements, PRs and PR Updates
9//! SHOULD be accepted with message 'purgatory: won't be served until git data arrives'
10//! and kept in purgatory (not served) until the related git data arrives and otherwise
11//! discarded after 30 minutes."
12//!
13//! ## Test Categories
14//!
15//! ### Announcement Purgatory (feature not yet implemented)
16//! - `test_announcement_not_served_before_git_data`
17//! - `test_announcement_served_after_git_push`
18//! - `test_bare_repo_exists_for_purgatory_announcement`
19//! - `test_state_event_accepted_for_purgatory_announcement`
20//!
21//! ### State Event Purgatory (already implemented)
22//! - `test_state_event_not_served_before_git_data`
23//! - `test_state_event_served_after_git_push`
24//!
25//! ### PR Purgatory (already implemented)
26//! - `test_pr_event_accepted_into_purgatory` - Event accepted, not queryable
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
29
30use crate::fixtures::{clone_repo, create_commit, try_push};
31use crate::specs::grasp01::SpecRef;
32use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
33use nostr_sdk::prelude::*;
34use std::fs;
35use std::time::Duration;
36
37/// Test suite for GRASP-01 purgatory behavior
38pub struct PurgatoryTests;
39
40impl PurgatoryTests {
41 /// Run all purgatory tests
42 pub async fn run_all(client: &AuditClient) -> AuditResult {
43 let mut results = AuditResult::new("GRASP-01 Purgatory Tests");
44
45 // Announcement purgatory tests (feature not yet implemented)
46 results.add(Self::test_announcement_not_served_before_git_data(client).await);
47 results.add(Self::test_announcement_served_after_git_push(client).await);
48 results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await);
49 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
50
51 // Deletion event tests (NIP-09)
52 results.add(Self::test_deletion_by_event_id_removes_purgatory_state_event(client).await);
53 results.add(
54 Self::test_deletion_by_coordinate_removes_purgatory_state_event(client).await,
55 );
56
57 // State event purgatory tests (already implemented)
58 results.add(Self::test_state_event_not_served_before_git_data(client).await);
59 results.add(Self::test_state_event_served_after_git_push(client).await);
60
61 // PR purgatory tests
62 results.add(Self::test_pr_event_accepted_into_purgatory_and_isnt_served(client).await);
63 results.add(Self::test_pr_event_in_purgatory_git_push_accepted(client).await);
64 results.add(Self::test_pr_event_served_after_git_push(client).await);
65
66 results
67 }
68
69 // ============================================================
70 // Announcement Purgatory Tests (#[ignore] - feature not yet implemented)
71 // ============================================================
72
73 /// Test: Repository announcement not served before git data arrives
74 ///
75 /// Spec: GRASP-01 Line 22
76 /// "New repository announcements... SHOULD be accepted with message
77 /// 'purgatory: won't be served until git data arrives' and kept in purgatory
78 /// (not served) until the related git data arrives"
79 ///
80 /// This test verifies:
81 /// 1. Send a valid repository announcement
82 /// 2. Event is accepted (OK response)
83 /// 3. Event is NOT queryable from the relay (in purgatory)
84 ///
85 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
86 pub async fn test_announcement_not_served_before_git_data(client: &AuditClient) -> TestResult {
87 TestResult::new(
88 "announcement_not_served_before_git_data",
89 SpecRef::PurgatoryAcceptUntilGitData,
90 "Repository announcements SHOULD be accepted but not served until git data arrives",
91 )
92 .run(|| async {
93 let ctx = TestContext::new(client);
94
95 // Create a fresh repo announcement (not the served variant)
96 let repo = ctx
97 .get_fixture(FixtureKind::ValidRepoSent)
98 .await
99 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
100
101 let repo_id = repo
102 .tags
103 .iter()
104 .find(|t| t.kind() == TagKind::d())
105 .and_then(|t| t.content())
106 .ok_or("Missing d tag in repo announcement")?
107 .to_string();
108
109 // Query for the announcement - should NOT be served
110 let filter = Filter::new()
111 .kind(Kind::GitRepoAnnouncement)
112 .author(client.public_key())
113 .identifier(&repo_id);
114
115 tokio::time::sleep(Duration::from_millis(300)).await;
116
117 let events = client
118 .query(filter)
119 .await
120 .map_err(|e| format!("Failed to query relay: {}", e))?;
121
122 if events.iter().any(|e| e.id == repo.id) {
123 return Err(format!(
124 "Announcement was served immediately - purgatory not implemented. \
125 Event ID: {} should NOT be queryable until git data arrives",
126 repo.id
127 ));
128 }
129
130 Ok(())
131 })
132 .await
133 }
134
135 /// Test: Repository announcement served after git push
136 ///
137 /// Spec: GRASP-01 Line 22
138 /// "...kept in purgatory (not served) until the related git data arrives"
139 ///
140 /// This test verifies the full lifecycle:
141 /// 1. Send repository announcement (enters purgatory)
142 /// 2. Send state event (enters purgatory)
143 /// 3. Push git data matching state event
144 /// 4. Both announcement and state event are now served
145 ///
146 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
147 pub async fn test_announcement_served_after_git_push(client: &AuditClient) -> TestResult {
148 TestResult::new(
149 "announcement_served_after_git_push",
150 SpecRef::PurgatoryAcceptUntilGitData,
151 "Repository announcements SHOULD be served after git data arrives",
152 )
153 .run(|| async {
154 let ctx = TestContext::new(client);
155
156 // OwnerStateDataPushed fixture handles the full lifecycle:
157 // 1. Creates repo announcement (purgatory)
158 // 2. Creates state event (purgatory)
159 // 3. Pushes git data
160 // 4. Verifies events are served
161 let state_event = ctx
162 .get_fixture(FixtureKind::OwnerStateDataPushed)
163 .await
164 .map_err(|e| format!("Failed to complete full lifecycle: {}", e))?;
165
166 // Extract repo_id from state event
167 let repo_id = state_event
168 .tags
169 .iter()
170 .find(|t| t.kind() == TagKind::d())
171 .and_then(|t| t.content())
172 .ok_or("Missing d tag in state event")?
173 .to_string();
174
175 // Verify announcement is now served
176 let announcement_filter = Filter::new()
177 .kind(Kind::GitRepoAnnouncement)
178 .author(client.public_key())
179 .identifier(&repo_id);
180
181 let announcements = client
182 .query(announcement_filter)
183 .await
184 .map_err(|e| format!("Failed to query announcements: {}", e))?;
185
186 if announcements.is_empty() {
187 return Err(format!(
188 "Announcement not served after git push. Repo ID: {}",
189 repo_id
190 ));
191 }
192
193 // Verify state event is served
194 let state_filter = Filter::new()
195 .kind(Kind::RepoState)
196 .author(client.public_key())
197 .identifier(&repo_id);
198
199 let state_events = client
200 .query(state_filter)
201 .await
202 .map_err(|e| format!("Failed to query state events: {}", e))?;
203
204 if !state_events.iter().any(|e| e.id == state_event.id) {
205 return Err(format!(
206 "State event not served after git push. Event ID: {}",
207 state_event.id
208 ));
209 }
210
211 Ok(())
212 })
213 .await
214 }
215
216 /// Test: Bare repository exists for purgatory announcement
217 ///
218 /// Spec: GRASP-01 Line 34
219 /// "MUST serve a git repository via an unauthenticated git smart http service
220 /// at `/<npub>/<identifier>.git` for each git repository announcement the relay
221 /// serves or has in purgatory."
222 ///
223 /// This test verifies that git HTTP service works even for repos in purgatory.
224 ///
225 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
226 pub async fn test_bare_repo_exists_for_purgatory_announcement(
227 client: &AuditClient,
228 ) -> TestResult {
229 TestResult::new(
230 "bare_repo_exists_for_purgatory_announcement",
231 SpecRef::GitServeRepository,
232 "Git HTTP service MUST work for repos in purgatory",
233 )
234 .run(|| async {
235 let ctx = TestContext::new(client);
236
237 // Get a repo announcement (in purgatory, no git data yet)
238 let repo = ctx
239 .get_fixture(FixtureKind::ValidRepoSent)
240 .await
241 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
242
243 let repo_id = repo
244 .tags
245 .iter()
246 .find(|t| t.kind() == TagKind::d())
247 .and_then(|t| t.content())
248 .ok_or("Missing d tag in repo announcement")?
249 .to_string();
250
251 let npub = client
252 .public_key()
253 .to_bech32()
254 .map_err(|e| format!("Failed to convert pubkey: {}", e))?;
255
256 // Get relay domain
257 let relay_url = client
258 .client()
259 .relays()
260 .await
261 .keys()
262 .next()
263 .ok_or("No relay connected")?
264 .to_string();
265 let relay_domain = relay_url
266 .replace("ws://", "")
267 .replace("wss://", "")
268 .replace(":8080", "");
269
270 // Check git HTTP service is available
271 let info_refs_url = format!(
272 "http://{}/{}/{}.git/info/refs?service=git-upload-pack",
273 relay_domain, npub, repo_id
274 );
275
276 let http_client = reqwest::Client::new();
277 let response = http_client
278 .get(&info_refs_url)
279 .send()
280 .await
281 .map_err(|e| format!("HTTP request failed: {}", e))?;
282
283 if !response.status().is_success() {
284 return Err(format!(
285 "Git HTTP service not available for purgatory repo. \
286 URL: {}, Status: {}",
287 info_refs_url,
288 response.status()
289 ));
290 }
291
292 Ok(())
293 })
294 .await
295 }
296
297 /// Test: State event accepted for purgatory announcement
298 ///
299 /// Spec: GRASP-01 Line 22
300 /// "New repository announcements, repo state announcements... SHOULD be accepted"
301 ///
302 /// This test verifies that state events are accepted even when the repo
303 /// announcement is in purgatory (no git data yet).
304 ///
305 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
306 pub async fn test_state_event_accepted_for_purgatory_announcement(
307 client: &AuditClient,
308 ) -> TestResult {
309 TestResult::new(
310 "state_event_accepted_for_purgatory_announcement",
311 SpecRef::PurgatoryAcceptUntilGitData,
312 "State events SHOULD be accepted for repos in purgatory",
313 )
314 .run(|| async {
315 let ctx = TestContext::new(client);
316
317 // Get a repo announcement (in purgatory)
318 let repo = ctx
319 .get_fixture(FixtureKind::ValidRepoSent)
320 .await
321 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
322
323 // Build a state event for this repo
324 let repo_id = repo
325 .tags
326 .iter()
327 .find(|t| t.kind() == TagKind::d())
328 .and_then(|t| t.content())
329 .ok_or("Missing d tag in repo announcement")?
330 .to_string();
331
332 let state_event = client
333 .event_builder(Kind::RepoState, "")
334 .tag(Tag::identifier(&repo_id))
335 .tag(Tag::custom(
336 TagKind::custom("refs/heads/main"),
337 vec!["abc123".to_string()],
338 ))
339 .tag(Tag::custom(
340 TagKind::custom("HEAD"),
341 vec!["ref: refs/heads/main".to_string()],
342 ))
343 .build(client.keys())
344 .map_err(|e| format!("Failed to build state event: {}", e))?;
345
346 // Send state event - should be accepted (even though repo is in purgatory)
347 let (_, in_purgatory) = client
348 .send_event_and_note_purgatory(state_event.clone())
349 .await
350 .map_err(|e| format!("Failed to send state event: {}", e))?;
351
352 // Event should be accepted (either in purgatory or served)
353 // We just verify it wasn't rejected
354 if !in_purgatory {
355 // Check if it's actually on the relay (might be served immediately)
356 let filter = Filter::new()
357 .kind(Kind::RepoState)
358 .author(client.public_key())
359 .identifier(&repo_id);
360
361 let events = client
362 .query(filter)
363 .await
364 .map_err(|e| format!("Failed to query: {}", e))?;
365
366 if events.iter().any(|e| e.id == state_event.id) {
367 return Err(format!(
368 "State event was served immediately - repo announcement purgatory not implemented. \
369 Event ID: {} should NOT be queryable until git data arrives",
370 state_event.id
371 ));
372 }
373
374 return Err(format!(
375 "State event was neither in purgatory nor served. \
376 Event ID: {}",
377 state_event.id
378 ));
379 }
380
381 // Feature IS implemented - state event in purgatory as expected
382 Ok(())
383 })
384 .await
385 }
386
387 // ============================================================
388 // State Event Purgatory Tests (non-ignored - already implemented)
389 // ============================================================
390
391 /// Test: State event not served before git data arrives
392 ///
393 /// Spec: GRASP-01 Line 22
394 /// "repo state announcements... SHOULD be accepted with message
395 /// 'purgatory: won't be served until git data arrives'"
396 ///
397 /// This test verifies:
398 /// 1. Send state event for a repo with git data
399 /// 2. State event points to a different commit than what's pushed
400 /// 3. State event is NOT queryable (in purgatory)
401 pub async fn test_state_event_not_served_before_git_data(client: &AuditClient) -> TestResult {
402 TestResult::new(
403 "state_event_not_served_before_git_data",
404 SpecRef::PurgatoryAcceptUntilGitData,
405 "State events SHOULD be accepted but not served until git data arrives",
406 )
407 .run(|| async {
408 let ctx = TestContext::new(client);
409
410 // Get a repo with git data already pushed
411 let existing_state = ctx
412 .get_fixture(FixtureKind::OwnerStateDataPushed)
413 .await
414 .map_err(|e| format!("Failed to get existing repo: {}", e))?;
415
416 let repo_id = existing_state
417 .tags
418 .iter()
419 .find(|t| t.kind() == TagKind::d())
420 .and_then(|t| t.content())
421 .ok_or("Missing d tag in state event")?
422 .to_string();
423
424 // Create a NEW state event pointing to a DIFFERENT commit
425 // This should enter purgatory since the commit doesn't exist
426 let new_state = client
427 .event_builder(Kind::RepoState, "")
428 .tag(Tag::identifier(&repo_id))
429 .tag(Tag::custom(
430 TagKind::custom("refs/heads/main"),
431 vec!["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string()],
432 ))
433 .tag(Tag::custom(
434 TagKind::custom("HEAD"),
435 vec!["ref: refs/heads/main".to_string()],
436 ))
437 .build(client.keys())
438 .map_err(|e| format!("Failed to build state event: {}", e))?;
439
440 // Send the state event
441 let (_, in_purgatory) = client
442 .send_event_and_note_purgatory(new_state.clone())
443 .await
444 .map_err(|e| format!("Failed to send state event: {}", e))?;
445
446 if !in_purgatory {
447 return Err(format!(
448 "State event was served immediately despite pointing to \
449 non-existent commit. Event ID: {}",
450 new_state.id
451 ));
452 }
453
454 Ok(())
455 })
456 .await
457 }
458
459 /// Test: State event served after git push
460 ///
461 /// Spec: GRASP-01 Line 22
462 /// "...kept in purgatory (not served) until the related git data arrives"
463 ///
464 /// This test verifies the full lifecycle using OwnerStateDataPushed fixture:
465 /// 1. State event is sent (enters purgatory)
466 /// 2. Git data is pushed matching the state event
467 /// 3. State event is now served
468 pub async fn test_state_event_served_after_git_push(client: &AuditClient) -> TestResult {
469 TestResult::new(
470 "state_event_served_after_git_push",
471 SpecRef::PurgatoryAcceptUntilGitData,
472 "State events SHOULD be served after matching git data arrives",
473 )
474 .run(|| async {
475 let ctx = TestContext::new(client);
476
477 // OwnerStateDataPushed handles the full lifecycle
478 let state_event = ctx
479 .get_fixture(FixtureKind::OwnerStateDataPushed)
480 .await
481 .map_err(|e| format!("Failed to complete full lifecycle: {}", e))?;
482
483 // Verify state event is now served
484 let repo_id = state_event
485 .tags
486 .iter()
487 .find(|t| t.kind() == TagKind::d())
488 .and_then(|t| t.content())
489 .ok_or("Missing d tag in state event")?
490 .to_string();
491
492 let filter = Filter::new()
493 .kind(Kind::RepoState)
494 .author(client.public_key())
495 .identifier(&repo_id);
496
497 let events = client
498 .query(filter)
499 .await
500 .map_err(|e| format!("Failed to query state events: {}", e))?;
501
502 if !events.iter().any(|e| e.id == state_event.id) {
503 return Err(format!(
504 "State event not served after git push. Event ID: {}",
505 state_event.id
506 ));
507 }
508
509 Ok(())
510 })
511 .await
512 }
513
514 // ============================================================
515 // PR Purgatory Tests
516 // ============================================================
517
518 /// Test: PR event accepted into purgatory (not served before git data)
519 ///
520 /// Spec: GRASP-01 Line 22
521 /// "PRs and PR Updates SHOULD be accepted with message
522 /// 'purgatory: won't be served until git data arrives'"
523 ///
524 /// This test verifies:
525 /// 1. PR event is sent and relay responds OK (accepted)
526 /// 2. PR event is NOT queryable (in purgatory, not served)
527 ///
528 /// PASS means: Relay accepted the event and is holding it in purgatory
529 /// FAIL means: Either event was rejected, or served immediately (purgatory not implemented)
530 ///
531 /// Note: This test cannot distinguish between "event in purgatory" and
532 /// "event accepted but never stored" - both result in event not being queryable.
533 /// The fixture verifies the relay responded OK, which is the best we can do
534 /// with black-box testing.
535 pub async fn test_pr_event_accepted_into_purgatory_and_isnt_served(
536 client: &AuditClient,
537 ) -> TestResult {
538 TestResult::new(
539 "pr_event_accepted_into_purgatory",
540 SpecRef::PurgatoryAcceptUntilGitData,
541 "PR event SHOULD be accepted but not served until git data arrives",
542 )
543 .run(|| async {
544 let ctx = TestContext::new(client);
545
546 // PREvent2Sent fixture:
547 // 1. Sends PR event
548 // 2. Verifies relay responded OK (not rejected)
549 // 3. Verifies event is NOT queryable (in purgatory)
550 let pr_event = ctx
551 .get_fixture(FixtureKind::PREvent2Sent)
552 .await
553 .map_err(|e| format!("Failed to send PR event: {}", e))?;
554
555 // Double-check: event should not be queryable
556 let filter = Filter::new()
557 .kind(Kind::GitPullRequest)
558 .author(client.pr_author_keys().public_key())
559 .id(pr_event.id);
560
561 tokio::time::sleep(Duration::from_millis(300)).await;
562
563 let events = client
564 .query(filter)
565 .await
566 .map_err(|e| format!("Failed to query PR events: {}", e))?;
567
568 if !events.is_empty() {
569 return Err(format!(
570 "PR event was served immediately - purgatory not implemented. Event ID: {}",
571 pr_event.id
572 ));
573 }
574
575 Ok(())
576 })
577 .await
578 }
579
580 /// Test: Git push to refs/nostr/<pr-event-id> is accepted
581 ///
582 /// This test verifies that pushing git data for a PR event in purgatory
583 /// is accepted by the relay.
584 ///
585 /// PASS means: Git push succeeded, relay accepted the git data
586 /// FAIL means: Git push was rejected (wrong ref, permissions, etc.)
587 pub async fn test_pr_event_in_purgatory_git_push_accepted(client: &AuditClient) -> TestResult {
588 TestResult::new(
589 "pr_event_in_purgatory_git_push_accepted",
590 SpecRef::PurgatoryAcceptUntilGitData,
591 "Git push for PR event SHOULD be accepted",
592 )
593 .run(|| async {
594 let ctx = TestContext::new(client);
595
596 // PREvent2GitDataPushed fixture:
597 // 1. Gets PR event in purgatory (PREvent2Sent)
598 // 2. Pushes commit to refs/nostr/<pr-event-id>
599 // 3. Verifies push succeeded
600 let _pr_event = ctx
601 .get_fixture(FixtureKind::PREvent2GitDataPushed)
602 .await
603 .map_err(|e| format!("Failed to push git data for PR event: {}", e))?;
604
605 Ok(())
606 })
607 .await
608 }
609
610 /// Test: PR event served after git data arrives
611 ///
612 /// This test verifies the full purgatory release mechanism:
613 /// after git data is pushed to refs/nostr/<pr-event-id>, the event
614 /// becomes queryable.
615 ///
616 /// PASS means: Event was released from purgatory and is now served
617 /// FAIL means: Event still not queryable after git push (purgatory release broken)
618 pub async fn test_pr_event_served_after_git_push(client: &AuditClient) -> TestResult {
619 TestResult::new(
620 "pr_event_served_after_git_push",
621 SpecRef::PurgatoryAcceptUntilGitData,
622 "PR event SHOULD be served after matching git data arrives",
623 )
624 .run(|| async {
625 let ctx = TestContext::new(client);
626
627 // PREvent2Served fixture:
628 // 1. Gets PR event with git data pushed (PREvent2GitDataPushed)
629 // 2. Verifies event is now queryable
630 let pr_event = ctx
631 .get_fixture(FixtureKind::PREvent2Served)
632 .await
633 .map_err(|e| format!("Failed to complete purgatory release: {}", e))?;
634
635 // Double-check: event should be queryable now
636 let filter = Filter::new()
637 .kind(Kind::GitPullRequest)
638 .author(client.pr_author_keys().public_key())
639 .id(pr_event.id);
640
641 let events = client
642 .query(filter)
643 .await
644 .map_err(|e| format!("Failed to query PR events: {}", e))?;
645
646 if events.is_empty() {
647 return Err(format!(
648 "PR event not served after git push. Event ID: {} should be queryable",
649 pr_event.id
650 ));
651 }
652
653 Ok(())
654 })
655 .await
656 }
657 // ============================================================
658 // Deletion Event Tests (NIP-09)
659 // ============================================================
660
661 /// Test: Kind 5 deletion event by event ID removes a purgatory state event
662 ///
663 /// Spec: NIP-09
664 /// "A special event with kind 5... having a list of one or more `e` or `a` tags,
665 /// each referencing an event the author is requesting to be deleted."
666 ///
667 /// This test verifies:
668 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
669 /// 2. Clone the repo and create a unique commit (not yet pushed)
670 /// 3. Submit a state event pointing to that unique commit (enters purgatory)
671 /// 4. Send a kind 5 deletion event referencing the state event by event ID
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(
674 client: &AuditClient,
675 ) -> TestResult {
676 TestResult::new(
677 "deletion_by_event_id_removes_purgatory_state_event",
678 SpecRef::PurgatoryAcceptUntilGitData,
679 "Kind 5 deletion by event ID SHOULD remove a purgatory state event, causing push rejection",
680 )
681 .run(|| async {
682 let ctx = TestContext::new(client);
683
684 // Stage 1: get a promoted repo with git data already on the relay
685 let existing_state = ctx
686 .get_fixture(FixtureKind::OwnerStateDataPushed)
687 .await
688 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
689
690 let repo_id = existing_state
691 .tags
692 .iter()
693 .find(|t| t.kind() == TagKind::d())
694 .and_then(|t| t.content())
695 .ok_or("Missing d tag in state event")?
696 .to_string();
697
698 let relay_domain = client
699 .relay_url()
700 .await
701 .map_err(|e| e.to_string())?
702 .trim_start_matches("ws://")
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 ));
749 }
750
751 // Stage 4: send kind 5 deletion event referencing the state event by event ID
752 let deletion = client
753 .event_builder(Kind::EventDeletion, "")
754 .tag(Tag::event(state_event.id))
755 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
756 .build(client.keys())
757 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
758
759 client
760 .send_event(deletion)
761 .await
762 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
763
764 tokio::time::sleep(Duration::from_millis(300)).await;
765
766 // Stage 5: attempt to push the unique commit — must be rejected
767 let push_result = try_push(&clone_path);
768 cleanup();
769
770 match push_result {
771 Ok(false) => Ok(()), // push rejected as expected
772 Ok(true) => Err(format!(
773 "Push was accepted but should have been rejected. \
774 The state event (id={}) was deleted, so commit {} \
775 should not be authorized.",
776 state_event.id, unique_commit
777 )),
778 Err(e) => Err(format!("Git push error: {}", e)),
779 }
780 })
781 .await
782 }
783
784 /// Test: Kind 5 deletion event by `a` tag coordinate removes a purgatory state event
785 ///
786 /// Spec: NIP-09
787 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable
788 /// event up to the `created_at` timestamp of the deletion request event."
789 ///
790 /// This test verifies:
791 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
792 /// 2. Generate a fresh keypair for a new maintainer
793 /// 3. Send a replacement owner announcement adding the new maintainer (goes to DB)
794 /// 4. Send a state event signed by the new maintainer pointing to a unique commit
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(
800 client: &AuditClient,
801 ) -> TestResult {
802 TestResult::new(
803 "deletion_by_coordinate_removes_purgatory_state_event",
804 SpecRef::PurgatoryAcceptUntilGitData,
805 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory state event, causing push rejection",
806 )
807 .run(|| async {
808 let ctx = TestContext::new(client);
809
810 // Stage 1: get a promoted repo with git data already on the relay
811 let existing_state = ctx
812 .get_fixture(FixtureKind::OwnerStateDataPushed)
813 .await
814 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
815
816 let repo_id = existing_state
817 .tags
818 .iter()
819 .find(|t| t.kind() == TagKind::d())
820 .and_then(|t| t.content())
821 .ok_or("Missing d tag in state event")?
822 .to_string();
823
824 // Stage 2: generate a fresh keypair for a new maintainer
825 let new_maintainer_keys = Keys::generate();
826 let new_maintainer_hex = new_maintainer_keys.public_key().to_hex();
827
828 // Stage 3: send a replacement owner announcement that adds the new maintainer.
829 // This is a replacement (same pubkey + identifier already in DB) so it goes
830 // straight to the database without entering purgatory.
831 let relay_url = client
832 .relay_url()
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())?;
842
843 let replacement_announcement = client
844 .event_builder(Kind::GitRepoAnnouncement, "")
845 .tag(Tag::identifier(&repo_id))
846 .tag(Tag::custom(
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 ))
858 .build(client.keys())
859 .map_err(|e| format!("Failed to build replacement announcement: {}", e))?;
860
861 client
862 .send_event(replacement_announcement)
863 .await
864 .map_err(|e| format!("Relay rejected replacement announcement: {}", e))?;
865
866 tokio::time::sleep(Duration::from_millis(200)).await;
867
868 // Stage 4: clone the repo and create a unique commit (not pushed yet)
869 let relay_domain = relay_url
870 .trim_start_matches("ws://")
871 .trim_start_matches("wss://")
872 .to_string();
873
874 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
875 .map_err(|e| format!("Failed to clone repo: {}", e))?;
876
877 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
878
879 let unique_commit = match create_commit(&clone_path, "deletion coordinate test unique commit") {
880 Ok(h) => h,
881 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
882 };
883
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();
908 return Err(format!(
909 "State event was served immediately (not in purgatory). \
910 Commit {} may already exist on relay.",
911 unique_commit
912 ));
913 }
914
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 }
948 })
949 .await
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use crate::AuditConfig;
957
958 #[tokio::test]
959 #[ignore] // Requires running relay
960 async fn test_grasp01_purgatory_against_relay() {
961 let relay_url = std::env::var("RELAY_URL").expect(
962 "RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081",
963 );
964
965 let config = AuditConfig::isolated();
966 let client = AuditClient::new(&relay_url, config)
967 .await
968 .unwrap_or_else(|_| {
969 panic!(
970 "Failed to connect to relay at {}. Ensure relay is running and accessible.",
971 relay_url
972 )
973 });
974
975 let results = PurgatoryTests::run_all(&client).await;
976 results.print_report();
977
978 assert!(
979 results.all_passed(),
980 "Some purgatory tests failed. See report above."
981 );
982 }
983}