diff options
Diffstat (limited to 'grasp-audit/src/specs/grasp01/purgatory.rs')
| -rw-r--r-- | grasp-audit/src/specs/grasp01/purgatory.rs | 983 |
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 | |||
| 30 | use crate::fixtures::{clone_repo, create_commit, try_push}; | ||
| 31 | use crate::specs::grasp01::SpecRef; | ||
| 32 | use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; | ||
| 33 | use nostr_sdk::prelude::*; | ||
| 34 | use std::fs; | ||
| 35 | use std::time::Duration; | ||
| 36 | |||
| 37 | /// Test suite for GRASP-01 purgatory behavior | ||
| 38 | pub struct PurgatoryTests; | ||
| 39 | |||
| 40 | impl 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)] | ||
| 954 | mod 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 | } | ||