//! Integration tests for GRASP-02 PR3: Maintainer Announcement Re-Processing //! //! Tests the two-tier rejected events index and immediate re-processing of //! maintainer announcements when owner announcements are accepted. use std::time::Duration; use nostr_sdk::prelude::*; use crate::common::{ sync_helpers::*, TestRelay, }; /// Test that maintainer announcements are re-processed immediately when owner announcement accepted /// /// Flow: /// 1. relay_a: Maintainer sends announcement (gets rejected - doesn't list relay_b) /// 2. relay_b: Owner sends announcement (lists relay_a + maintainer) /// 3. relay_b syncs from relay_a, maintainer announcement enters rejected index /// 4. relay_b processes owner announcement, invalidates and re-processes maintainer announcement /// 5. Both announcements should be in relay_b's database /// /// Expected time: <5 seconds (vs 24 hours without hot cache) #[tokio::test] async fn test_maintainer_announcement_reprocessed_immediately() { // Start relay_a (where maintainer announcement will be sent) let relay_a = TestRelay::start().await; println!("relay_a started at {}", relay_a.url()); // Start relay_b with sync enabled (will sync from relay_a) let relay_b = TestRelay::start_with_sync(None).await; println!("relay_b started at {}", relay_b.url()); // Create keys let owner_keys = Keys::generate(); let maintainer_keys = Keys::generate(); let identifier = "test-repo"; let start = std::time::Instant::now(); // Step 1: Send maintainer announcement to relay_a (will be rejected by relay_b - doesn't list relay_b) // Use HTTP clone URL pointing to relay_a's git endpoint so it can be released from purgatory let maintainer_npub = maintainer_keys .public_key() .to_bech32() .expect("Failed to get npub"); let maintainer_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!( "http://{}/{}/{}.git", relay_a.domain(), maintainer_npub, identifier )], ), Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]), ]) .sign_with_keys(&maintainer_keys) .unwrap(); send_to_relay(&relay_a, &maintainer_announcement) .await .unwrap(); println!("✓ Maintainer announcement sent to relay_a"); // Push git data for maintainer's repo to relay_a → releases maintainer announcement from purgatory let _git_dir_maintainer = push_git_data_to_relay( &relay_a, &maintainer_keys, identifier, &[&relay_a.domain()], ) .await; println!("✓ Maintainer git data pushed to relay_a (announcement released from purgatory)"); // Step 2: Set up owner announcement on relay_b (lists relay_a + maintainer) with git data let owner_npub = owner_keys .public_key() .to_bech32() .expect("Failed to get npub"); let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!( "http://{}/{}/{}.git", relay_b.domain(), owner_npub, identifier )], ), Tag::custom( TagKind::custom("relays"), vec![relay_a.url().to_string(), relay_b.url().to_string()], ), Tag::custom( TagKind::custom("maintainers"), vec![maintainer_keys.public_key().to_hex()], ), ]) .sign_with_keys(&owner_keys) .unwrap(); send_to_relay(&relay_b, &owner_announcement).await.unwrap(); println!("✓ Owner announcement sent to relay_b"); // Push git data for owner's repo to relay_b → releases owner announcement from purgatory let _git_dir_owner = push_git_data_to_relay(&relay_b, &owner_keys, identifier, &[&relay_b.domain()]).await; println!("✓ Owner git data pushed to relay_b (announcement released from purgatory)"); // Step 3: Wait for sync and re-processing (relay_b discovers relay_a, syncs, re-processes) tokio::time::sleep(Duration::from_secs(3)).await; let elapsed = start.elapsed(); // Step 4: Verify both announcements are in relay_b's database let owner_filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(owner_keys.public_key()) .identifier(identifier); let owner_found = wait_for_event_on_relay(relay_b.url(), owner_filter, Duration::from_secs(2)).await; assert!(owner_found, "Owner announcement should be in relay_b"); let maintainer_filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(maintainer_keys.public_key()) .identifier(identifier); let maintainer_found = wait_for_event_on_relay(relay_b.url(), maintainer_filter, Duration::from_secs(2)).await; assert!( maintainer_found, "Maintainer announcement should be re-processed and accepted in relay_b" ); // Step 5: Verify it happened quickly (not 24 hours!) assert!( elapsed.as_secs() < 15, "Re-processing should happen in <15 seconds, took {:?}", elapsed ); println!("✅ Maintainer announcement re-processed in {:?}", elapsed); relay_a.stop().await; relay_b.stop().await; } /// Test that maintainer announcements NOT in hot cache are still prevented from re-fetching /// /// Flow: /// 1. Maintainer announcement arrives → Rejected (added to hot cache + cold index) /// 2. Wait for hot cache to expire (2+ minutes) /// 3. Owner announcement arrives → Invalidates cold index /// 4. Maintainer announcement should NOT be re-fetched (cold index prevents) /// 5. Only owner announcement should be in database /// /// This test verifies the cold index prevents repeated downloads after hot cache expiry. /// Note: This test is slow (2+ minutes) so we'll skip it in normal test runs. #[tokio::test] #[ignore] // Skip by default due to 2+ minute duration async fn test_maintainer_announcement_cold_index_prevents_refetch() { let relay = TestRelay::start().await; // Create keys let owner_keys = Keys::generate(); let maintainer_keys = Keys::generate(); let identifier = "test-repo-cold"; // Create client using TestClient helper let client = TestClient::new(relay.url(), maintainer_keys.clone()) .await .expect("Failed to connect to relay"); // Step 1: Send maintainer announcement (will be rejected - doesn't list our relay) let maintainer_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!("https://example.com/{}.git", identifier)], ), Tag::custom( TagKind::custom("relays"), vec!["wss://example.com".to_string()], ), ]) .sign_with_keys(&maintainer_keys) .unwrap(); // Send maintainer announcement - expect it to be rejected let _ = client.send_event(&maintainer_announcement).await; tokio::time::sleep(Duration::from_millis(200)).await; // Step 2: Wait for hot cache to expire (default: 120 seconds) println!("⏳ Waiting for hot cache to expire (120 seconds)..."); tokio::time::sleep(Duration::from_secs(125)).await; // Step 3: Send owner announcement (lists maintainer) let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!("https://{}/{}.git", relay.domain(), identifier)], ), Tag::custom(TagKind::custom("relays"), vec![relay.url().to_string()]), Tag::custom( TagKind::custom("maintainers"), vec![maintainer_keys.public_key().to_hex()], ), ]) .sign_with_keys(&owner_keys) .unwrap(); client.send_event(&owner_announcement).await.unwrap(); tokio::time::sleep(Duration::from_millis(500)).await; // Step 4: Verify only owner announcement is in database let owner_filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(owner_keys.public_key()) .identifier(identifier); let owner_found = wait_for_event_on_relay(relay.url(), owner_filter, Duration::from_secs(2)).await; assert!(owner_found, "Owner announcement should be accepted"); let maintainer_filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(maintainer_keys.public_key()) .identifier(identifier); let maintainer_found = wait_for_event_on_relay(relay.url(), maintainer_filter, Duration::from_millis(500)).await; assert!( !maintainer_found, "Maintainer announcement should NOT be re-processed (hot cache expired)" ); println!("✅ Cold index prevented re-fetch after hot cache expiry"); client.disconnect().await; relay.stop().await; } /// Test multiple maintainers are all re-processed when owner announcement accepted /// /// Flow: /// 1. relay_a: Three maintainers send announcements (get rejected - don't list relay_b) /// 2. relay_b: Owner sends announcement (lists relay_a + all three maintainers) /// 3. relay_b syncs from relay_a, all maintainer announcements enter rejected index /// 4. relay_b processes owner announcement, invalidates and re-processes all maintainer announcements /// 5. All four announcements should be in relay_b's database #[tokio::test] async fn test_multiple_maintainers_all_reprocessed() { // Start relay_a (where maintainer announcements will be sent) let relay_a = TestRelay::start().await; println!("relay_a started at {}", relay_a.url()); // Start relay_b with sync enabled (will sync from relay_a) let relay_b = TestRelay::start_with_sync(None).await; println!("relay_b started at {}", relay_b.url()); // Create keys let owner_keys = Keys::generate(); let maintainer1_keys = Keys::generate(); let maintainer2_keys = Keys::generate(); let maintainer3_keys = Keys::generate(); let identifier = "multi-maintainer-repo"; // Step 1: Send three maintainer announcements to relay_a with git data // (purgatory requires git data before announcements are accepted) let mut git_dirs_maintainers = Vec::new(); for (idx, maintainer_keys) in [&maintainer1_keys, &maintainer2_keys, &maintainer3_keys] .iter() .enumerate() { let m_npub = maintainer_keys .public_key() .to_bech32() .expect("Failed to get npub"); let announcement = EventBuilder::new( Kind::GitRepoAnnouncement, format!("Maintainer {} repository", idx + 1), ) .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!( "http://{}/{}/{}.git", relay_a.domain(), m_npub, identifier )], ), Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]), ]) .sign_with_keys(maintainer_keys) .unwrap(); send_to_relay(&relay_a, &announcement).await.unwrap(); // Push git data to release each maintainer's announcement from purgatory let git_dir = push_git_data_to_relay(&relay_a, maintainer_keys, identifier, &[&relay_a.domain()]) .await; git_dirs_maintainers.push(git_dir); } println!("✓ Three maintainer announcements sent to relay_a with git data"); // Step 2: Send owner announcement to relay_b (lists relay_a + all three maintainers) let owner_npub = owner_keys .public_key() .to_bech32() .expect("Failed to get npub"); let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!( "http://{}/{}/{}.git", relay_b.domain(), owner_npub, identifier )], ), Tag::custom( TagKind::custom("relays"), vec![relay_a.url().to_string(), relay_b.url().to_string()], ), Tag::custom( TagKind::custom("maintainers"), vec![ maintainer1_keys.public_key().to_hex(), maintainer2_keys.public_key().to_hex(), maintainer3_keys.public_key().to_hex(), ], ), ]) .sign_with_keys(&owner_keys) .unwrap(); send_to_relay(&relay_b, &owner_announcement).await.unwrap(); println!("✓ Owner announcement sent to relay_b"); // Push git data for owner to relay_b → releases owner announcement from purgatory let _git_dir_owner = push_git_data_to_relay(&relay_b, &owner_keys, identifier, &[&relay_b.domain()]).await; println!("✓ Owner git data pushed to relay_b (announcement released from purgatory)"); // Step 3: Wait for sync and re-processing tokio::time::sleep(Duration::from_secs(3)).await; // Step 4: Verify all four announcements are in relay_b's database for (name, keys) in [ ("owner", &owner_keys), ("maintainer1", &maintainer1_keys), ("maintainer2", &maintainer2_keys), ("maintainer3", &maintainer3_keys), ] { let filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(keys.public_key()) .identifier(identifier); let found = wait_for_event_on_relay(relay_b.url(), filter, Duration::from_secs(2)).await; assert!(found, "{} announcement should be in relay_b", name); } println!("✅ All three maintainer announcements re-processed successfully"); relay_a.stop().await; relay_b.stop().await; } /// Test that invalid maintainer public keys don't cause panics /// /// Flow: /// 1. Maintainer announcement arrives → Rejected /// 2. Owner announcement arrives with INVALID maintainer hex → Should handle gracefully /// 3. Owner announcement should still be accepted /// 4. Maintainer announcement should NOT be re-processed (invalid pubkey) #[tokio::test] async fn test_invalid_maintainer_pubkey_handled_gracefully() { let relay = TestRelay::start().await; // Create keys let owner_keys = Keys::generate(); let maintainer_keys = Keys::generate(); let identifier = "invalid-maintainer-repo"; // Step 1: Send maintainer announcement (will be rejected - doesn't list our relay) // This one uses example.com clone URL - it goes to purgatory on relay, never promoted let maintainer_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!("https://example.com/{}.git", identifier)], ), Tag::custom( TagKind::custom("relays"), vec!["wss://example.com".to_string()], ), ]) .sign_with_keys(&maintainer_keys) .unwrap(); // Send maintainer announcement - expect it to be rejected (purgatory / policy) send_to_relay(&relay, &maintainer_announcement).await.ok(); tokio::time::sleep(Duration::from_millis(200)).await; // Step 2: Set up owner announcement with INVALID maintainer hex and git data // Use HTTP clone URL to relay's git endpoint so it can be released from purgatory let owner_npub = owner_keys .public_key() .to_bech32() .expect("Failed to get npub"); let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") .tags(vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!( "http://{}/{}/{}.git", relay.domain(), owner_npub, identifier )], ), Tag::custom(TagKind::custom("relays"), vec![relay.url().to_string()]), Tag::custom( TagKind::custom("maintainers"), vec!["invalid-hex-not-a-pubkey".to_string()], ), ]) .sign_with_keys(&owner_keys) .unwrap(); send_to_relay(&relay, &owner_announcement).await.unwrap(); // Push git data to relay → releases owner announcement from purgatory let _git_dir = push_git_data_to_relay(&relay, &owner_keys, identifier, &[&relay.domain()]).await; println!("✓ Owner git data pushed to relay (announcement released from purgatory)"); // Wait for processing tokio::time::sleep(Duration::from_millis(500)).await; // Step 3: Verify owner announcement accepted, maintainer not re-processed let owner_filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(owner_keys.public_key()) .identifier(identifier); let owner_found = wait_for_event_on_relay(relay.url(), owner_filter, Duration::from_secs(2)).await; assert!( owner_found, "Owner announcement should be accepted despite invalid maintainer" ); let maintainer_filter = Filter::new() .kind(Kind::GitRepoAnnouncement) .author(maintainer_keys.public_key()) .identifier(identifier); let maintainer_found = wait_for_event_on_relay(relay.url(), maintainer_filter, Duration::from_millis(500)).await; assert!( !maintainer_found, "Maintainer announcement should NOT be re-processed (invalid pubkey)" ); println!("✅ Invalid maintainer pubkey handled gracefully without panic"); relay.stop().await; }