From 1d09e4bdea7e328cf2740818df9df660c5532a99 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 13 Feb 2026 13:24:46 +0000 Subject: feat: implement announcement purgatory core (breaks archive sync test) Route new announcements to purgatory instead of accepting immediately. Announcements are promoted to the database when git data arrives, ensuring we only serve announcements for repos with actual content. Implemented: - AnnouncementPurgatoryEntry type and DashMap store - Route new announcements to purgatory (replacement announcements skip) - Promote announcements on git data arrival (process_purgatory_announcements) - Authorization checks purgatory announcements (fetch_repository_data_with_purgatory) - State policy uses purgatory announcements for maintainer validation - Cleanup task handles announcement expiry - Updated count()/cleanup() to 3-tuples Known broken: - test_archive_read_only_creates_bare_repo fails: sync module does not treat purgatory announcements as confirmed repos, so per-repo sync (state events, PRs) is never triggered for purgatory announcements - Announcement persistence (save/restore) not implemented - SyncLevel (StateOnly vs Full) not implemented - Soft expiry two-phase not implemented - Expiry extension on state event / git auth not wired up --- tests/archive_read_only.rs | 59 ++++++++++++++++++++++++++++++++---------- tests/purgatory.rs | 4 +-- tests/purgatory_persistence.rs | 26 ++++++++++--------- 3 files changed, 61 insertions(+), 28 deletions(-) (limited to 'tests') diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs index be6959b..e39b4b2 100644 --- a/tests/archive_read_only.rs +++ b/tests/archive_read_only.rs @@ -165,6 +165,7 @@ async fn test_archive_read_only_creates_bare_repo() { // c) Put state event in purgatory (git data missing on archive relay) // d) Fetch git data from source relay's clone URL // e) Release the state event from purgatory + let found = wait_for_event_served( archive_relay.url(), &state_event_id, @@ -267,11 +268,13 @@ async fn test_archive_read_only_creates_bare_repo() { /// This verifies the security model: archive mode only syncs git data /// when there are state events to validate against. /// -/// Scenario: -/// 1. Start source relay with announcement only (no state events) -/// 2. Start archive relay syncing from source -/// 3. Archive relay syncs announcement (creates bare repo) -/// 4. Verify git data is NOT synced (no state events to trigger purgatory sync) +/// With announcement purgatory, the flow is: +/// 1. Send announcement to source relay (goes to purgatory) +/// 2. Send state event to source relay (goes to purgatory) +/// 3. Push git data to source relay (promotes announcement and state event) +/// 4. Start archive relay with sync from source +/// 5. Archive relay syncs the promoted announcement +/// 6. Verify git data is NOT synced (archive has no state event to authorize git fetch) #[tokio::test] async fn test_archive_without_state_events_does_not_sync_git() { // 1. Start source relay @@ -290,7 +293,7 @@ async fn test_archive_without_state_events_does_not_sync_git() { let npub = keys.public_key().to_bech32().expect("Failed to get npub"); - // 3. Create and send announcement listing BOTH relays (but NO state event) + // 3. Create and send announcement listing BOTH relays let announcement = create_repo_announcement( &keys, &[&source_relay.domain(), &archive_domain], @@ -306,7 +309,7 @@ async fn test_archive_without_state_events_does_not_sync_git() { tokio::time::sleep(Duration::from_millis(500)).await; - // Send announcement to source relay + // Send announcement to source relay (goes to purgatory) source_client .send_event(&announcement) .await @@ -314,11 +317,39 @@ async fn test_archive_without_state_events_does_not_sync_git() { tokio::time::sleep(Duration::from_millis(200)).await; - // 4. Push git data to source relay (but no state event to authorize it) - // This push will fail because there's no state event in purgatory - // That's expected - we're testing that archive mode doesn't blindly fetch git data + // 4. Create and send state event to source relay (goes to purgatory) + let clone_url = format!( + "http://{}/{}/{}.git", + source_relay.domain(), + npub, + identifier + ); + let relay_url = source_relay.url().to_string(); + + let state_event = create_state_event( + &keys, + identifier, + &[("main", &commit_hash)], + &[], + &[&clone_url], + &[&relay_url], + ) + .expect("Failed to create state event"); + + source_client + .send_event(&state_event) + .await + .expect("Failed to send state event to source"); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // 5. Push git data to source relay (promotes announcement and state event) + push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier) + .expect("Push to source should succeed"); + + tokio::time::sleep(Duration::from_millis(500)).await; - // 5. Start archive relay + // 6. Start archive relay (without state event - we don't send state event to archive) let archive_relay = TestRelay::start_with_archive_and_sync( archive_port, Some(source_relay.url().to_string()), @@ -333,10 +364,10 @@ async fn test_archive_without_state_events_does_not_sync_git() { .await .expect("Sync connection should establish"); - // Give time for any potential git sync to happen + // Give time for sync to fetch announcement tokio::time::sleep(Duration::from_secs(3)).await; - // 6. Verify bare repository was created (announcement was accepted) + // 7. Verify bare repository was created (announcement was synced and accepted to purgatory) let repo_path = archive_relay .git_data_path() .join(format!("{}/{}.git", npub, identifier)); @@ -346,7 +377,7 @@ async fn test_archive_without_state_events_does_not_sync_git() { "Bare repository should be created for archive announcement" ); - // 7. Verify git data was NOT synced (no state events to trigger purgatory sync) + // 8. Verify git data was NOT synced (no state events on archive to trigger git fetch) // Check that the commit does NOT exist in the archive relay's repo let output = tokio::process::Command::new("git") .args(["cat-file", "-t", &commit_hash]) diff --git a/tests/purgatory.rs b/tests/purgatory.rs index e99540b..efc28c9 100644 --- a/tests/purgatory.rs +++ b/tests/purgatory.rs @@ -58,10 +58,10 @@ macro_rules! isolated_purgatory_test { } // ============================================================ -// Announcement Purgatory Tests (commented out - feature not yet implemented) +// Announcement Purgatory Tests // ============================================================ -// isolated_purgatory_test!(test_announcement_not_served_before_git_data); +isolated_purgatory_test!(test_announcement_not_served_before_git_data); isolated_purgatory_test!(test_announcement_served_after_git_push); isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement); isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement); diff --git a/tests/purgatory_persistence.rs b/tests/purgatory_persistence.rs index fe37c33..5abbf15 100644 --- a/tests/purgatory_persistence.rs +++ b/tests/purgatory_persistence.rs @@ -120,7 +120,8 @@ async fn test_full_purgatory_save_restore_cycle() { // so we'll focus on testing state and PR events persistence // Verify initial counts - let (state_count, pr_count) = purgatory.count(); + let (announcement_count, state_count, pr_count) = purgatory.count(); + assert_eq!(announcement_count, 0, "Should have 0 announcements"); assert_eq!(state_count, 2, "Should have 2 state events"); assert_eq!( pr_count, 3, @@ -142,7 +143,8 @@ async fn test_full_purgatory_save_restore_cycle() { ); // Verify all data was restored - let (state_count2, pr_count2) = purgatory2.count(); + let (announcement_count2, state_count2, pr_count2) = purgatory2.count(); + assert_eq!(announcement_count2, 0, "Should have 0 announcements after restore"); assert_eq!(state_count2, 2, "Should have 2 state events after restore"); assert_eq!( pr_count2, 3, @@ -275,7 +277,7 @@ async fn test_purgatory_downtime_adjustment() { purgatory2.restore_from_disk(&state_path).unwrap(); // Verify event is still there (downtime was accounted for) - let (state_count, _) = purgatory2.count(); + let (_, state_count, _) = purgatory2.count(); assert_eq!(state_count, 1); let repo1_states = purgatory2.find_state("repo1"); @@ -401,7 +403,7 @@ async fn test_purgatory_restore_missing_file() { assert!(result.is_err(), "Should error on missing file"); // Purgatory should still be usable (empty state) - let (state_count, pr_count) = purgatory.count(); + let (_, state_count, pr_count) = purgatory.count(); assert_eq!(state_count, 0); assert_eq!(pr_count, 0); @@ -410,7 +412,7 @@ async fn test_purgatory_restore_missing_file() { let event = create_test_event(&keys, "test").await; purgatory.add_state(event, "repo1".to_string(), keys.public_key()); - let (state_count, _) = purgatory.count(); + let (_, state_count, _) = purgatory.count(); assert_eq!(state_count, 1); } @@ -461,7 +463,7 @@ async fn test_purgatory_restore_corrupted_file() { assert!(result.is_err(), "Should error on corrupted file"); // Purgatory should still be usable - let (state_count, pr_count) = purgatory.count(); + let (_, state_count, pr_count) = purgatory.count(); assert_eq!(state_count, 0); assert_eq!(pr_count, 0); } @@ -504,7 +506,7 @@ async fn test_empty_purgatory_save_restore() { purgatory2.restore_from_disk(&state_path).unwrap(); // Verify empty state - let (state_count, pr_count) = purgatory2.count(); + let (_, state_count, pr_count) = purgatory2.count(); assert_eq!(state_count, 0); assert_eq!(pr_count, 0); assert_eq!(purgatory2.expired_count(), 0); @@ -591,7 +593,7 @@ async fn test_purgatory_continues_working_after_restore() { purgatory2.add_state(event2.clone(), "repo2".to_string(), keys.public_key()); // Verify both old and new events work - let (state_count, _) = purgatory2.count(); + let (_, state_count, _) = purgatory2.count(); assert_eq!(state_count, 2); let repo1_states = purgatory2.find_state("repo1"); @@ -603,7 +605,7 @@ async fn test_purgatory_continues_working_after_restore() { assert_eq!(repo2_states[0].event.id, event2.id); // Verify cleanup still works - let (state_removed, pr_removed) = purgatory2.cleanup(); + let (_, state_removed, pr_removed) = purgatory2.cleanup(); // Nothing should be expired yet assert_eq!(state_removed, 0); assert_eq!(pr_removed, 0); @@ -684,15 +686,15 @@ async fn test_purgatory_entries_expired_during_downtime() { purgatory2.restore_from_disk(&state_path).unwrap(); // Event should be restored - let (state_count, _) = purgatory2.count(); + let (_, state_count, _) = purgatory2.count(); assert_eq!(state_count, 1); // Cleanup should work (even if nothing is expired yet) - let (state_removed, _) = purgatory2.cleanup(); + let (_, state_removed, _) = purgatory2.cleanup(); // Nothing expired yet since we didn't wait 30 minutes assert_eq!(state_removed, 0); - let (state_count, _) = purgatory2.count(); + let (_, state_count, _) = purgatory2.count(); assert_eq!(state_count, 1); } -- cgit v1.2.3