//! Archive GRASP Services Integration Tests //! //! Tests that verify archive_grasp_services filtering behavior: //! - Announcements with matching GRASP service domains are accepted //! - Announcements with non-matching GRASP service domains are rejected //! - Multiple configured services work correctly //! - Case-insensitive domain matching //! //! # Test Strategy //! //! These tests verify the GRASP-05 archive mode with grasp_services filtering: //! 1. Configure relay with specific GRASP service domains //! 2. Send announcements with various clone URLs //! 3. Verify announcements are accepted/rejected based on domain matching //! 4. Verify repositories are created only for accepted announcements //! //! # Running Tests //! //! ```bash //! # Run all archive grasp services tests //! cargo test --test archive_grasp_services //! //! # Run specific test //! cargo test --test archive_grasp_services test_archive_accepts_matching_grasp_service //! //! # With output for debugging //! cargo test --test archive_grasp_services -- --nocapture //! ``` mod common; use common::{ check_ref_at_commit, create_repo_announcement, create_state_event, create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection, CommitVariant, TestRelay, }; use nostr_sdk::prelude::*; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::Duration; /// Helper to start a relay with archive_grasp_services configuration /// /// This is a specialized version of TestRelay::start_with_archive_and_sync /// that adds the NGIT_ARCHIVE_GRASP_SERVICES environment variable. async fn start_relay_with_grasp_services(services: &str) -> (Child, String, PathBuf) { let port = TestRelay::find_free_port(); let bind_address = format!("127.0.0.1:{}", port); let url = format!("ws://127.0.0.1:{}", port); // Create temporary directory for git repositories let git_data_dir = tempfile::tempdir().expect("Failed to create temporary git data directory"); // Use the built binary directly let binary_path = std::env::current_exe() .expect("Failed to get current exe") .parent() .expect("Failed to get parent dir") .parent() .expect("Failed to get grandparent dir") .join("ngit-grasp"); // Generate a test owner npub let test_keys = nostr_sdk::Keys::generate(); let test_npub = test_keys .public_key() .to_bech32() .expect("Failed to generate test npub"); // Start the relay process with archive_grasp_services let mut cmd = Command::new(&binary_path); cmd.env("NGIT_BIND_ADDRESS", &bind_address) .env("NGIT_DOMAIN", &bind_address) .env("NGIT_GIT_DATA_PATH", git_data_dir.path()) .env("NGIT_DATABASE_BACKEND", "memory") .env("NGIT_OWNER_NPUB", &test_npub) .env("NGIT_ARCHIVE_GRASP_SERVICES", services) .env( "RUST_LOG", std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), ) .stdout(Stdio::null()) .stderr(Stdio::null()); let process = cmd.spawn().expect("Failed to start relay process"); // Store git data path for test assertions let git_data_path = git_data_dir.path().to_path_buf(); // Wait for relay to be ready wait_for_relay_ready(port).await; (process, url, git_data_path) } /// Wait for the relay to be ready to accept connections async fn wait_for_relay_ready(port: u16) { let max_attempts = 50; // 5 seconds total let delay = Duration::from_millis(100); for attempt in 0..max_attempts { // Try to connect to the relay match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { Ok(_) => { // Connection successful, relay is ready // Give it a tiny bit more time to fully initialize tokio::time::sleep(Duration::from_millis(100)).await; return; } Err(_) => { if attempt == max_attempts - 1 { panic!("Relay failed to start after {} attempts", max_attempts); } tokio::time::sleep(delay).await; } } } } /// Test that announcements with matching GRASP service domains are accepted. /// /// Scenario: /// 1. Start relay with archive_grasp_services="git.example.com" /// 2. Send announcement with clone URL from git.example.com /// 3. Verify announcement is accepted (repository is created) #[tokio::test] async fn test_archive_accepts_matching_grasp_service() { let (mut process, url, git_data_path) = start_relay_with_grasp_services("git.example.com").await; let keys = Keys::generate(); let identifier = "test-repo"; // Create announcement with clone URL from git.example.com let npub = keys.public_key().to_bech32().expect("Failed to get npub"); let tags = vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!("https://git.example.com/user/{}.git", identifier)], ), Tag::custom( TagKind::custom("relays"), vec!["wss://relay.example.com".to_string()], ), ]; let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") .tags(tags) .sign_with_keys(&keys) .expect("Failed to sign announcement"); // Send announcement to relay let client = Client::new(keys.clone()); client.add_relay(&url).await.expect("Failed to add relay"); client.connect().await; tokio::time::sleep(Duration::from_millis(500)).await; client .send_event(&announcement) .await .expect("Failed to send announcement"); tokio::time::sleep(Duration::from_millis(500)).await; // Verify repository was created (announcement was accepted) let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier)); assert!( repo_path.exists(), "Repository should be created for announcement with matching GRASP service domain" ); // Cleanup client.disconnect().await; let _ = process.kill(); let _ = process.wait(); } /// Test that announcements with non-matching GRASP service domains are rejected. /// /// Scenario: /// 1. Start relay with archive_grasp_services="git.example.com" /// 2. Send announcement with clone URL from github.com (not in services list) /// 3. Verify announcement is rejected (repository is NOT created) #[tokio::test] async fn test_archive_rejects_non_matching_grasp_service() { let (mut process, url, git_data_path) = start_relay_with_grasp_services("git.example.com").await; let keys = Keys::generate(); let identifier = "test-repo"; // Create announcement with clone URL from github.com (NOT in services list) let npub = keys.public_key().to_bech32().expect("Failed to get npub"); let tags = vec![ Tag::identifier(identifier), Tag::custom( TagKind::custom("clone"), vec![format!("https://github.com/user/{}.git", identifier)], ), Tag::custom( TagKind::custom("relays"), vec!["wss://relay.example.com".to_string()], ), ]; let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") .tags(tags) .sign_with_keys(&keys) .expect("Failed to sign announcement"); // Send announcement to relay let client = Client::new(keys.clone()); client.add_relay(&url).await.expect("Failed to add relay"); client.connect().await; tokio::time::sleep(Duration::from_millis(500)).await; client .send_event(&announcement) .await .expect("Failed to send announcement"); tokio::time::sleep(Duration::from_millis(500)).await; // Verify repository was NOT created (announcement was rejected) let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier)); assert!( !repo_path.exists(), "Repository should NOT be created for announcement with non-matching GRASP service domain" ); // Cleanup client.disconnect().await; let _ = process.kill(); let _ = process.wait(); } /// Test that multiple configured GRASP services work correctly. /// /// Scenario: /// 1. Start relay with archive_grasp_services="git.example.com,gitlab.example.org" /// 2. Send announcements with clone URLs from both services /// 3. Verify both announcements are accepted /// 4. Send announcement from non-listed service /// 5. Verify it is rejected #[tokio::test] async fn test_archive_multiple_grasp_services() { let (mut process, url, git_data_path) = start_relay_with_grasp_services("git.example.com,gitlab.example.org").await; // Test first service (git.example.com) let keys1 = Keys::generate(); let identifier1 = "test-repo-1"; let npub1 = keys1.public_key().to_bech32().expect("Failed to get npub"); let tags1 = vec![ Tag::identifier(identifier1), Tag::custom( TagKind::custom("clone"), vec![format!("https://git.example.com/user/{}.git", identifier1)], ), Tag::custom( TagKind::custom("relays"), vec!["wss://relay.example.com".to_string()], ), ]; let announcement1 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") .tags(tags1) .sign_with_keys(&keys1) .expect("Failed to sign announcement"); let client1 = Client::new(keys1.clone()); client1.add_relay(&url).await.expect("Failed to add relay"); client1.connect().await; tokio::time::sleep(Duration::from_millis(500)).await; client1 .send_event(&announcement1) .await .expect("Failed to send announcement"); tokio::time::sleep(Duration::from_millis(500)).await; // Test second service (gitlab.example.org) let keys2 = Keys::generate(); let identifier2 = "test-repo-2"; let npub2 = keys2.public_key().to_bech32().expect("Failed to get npub"); let tags2 = vec![ Tag::identifier(identifier2), Tag::custom( TagKind::custom("clone"), vec![format!( "https://gitlab.example.org/user/{}.git", identifier2 )], ), Tag::custom( TagKind::custom("relays"), vec!["wss://relay.example.com".to_string()], ), ]; let announcement2 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") .tags(tags2) .sign_with_keys(&keys2) .expect("Failed to sign announcement"); let client2 = Client::new(keys2.clone()); client2.add_relay(&url).await.expect("Failed to add relay"); client2.connect().await; tokio::time::sleep(Duration::from_millis(500)).await; client2 .send_event(&announcement2) .await .expect("Failed to send announcement"); tokio::time::sleep(Duration::from_millis(500)).await; // Test non-listed service (github.com) let keys3 = Keys::generate(); let identifier3 = "test-repo-3"; let npub3 = keys3.public_key().to_bech32().expect("Failed to get npub"); let tags3 = vec![ Tag::identifier(identifier3), Tag::custom( TagKind::custom("clone"), vec![format!("https://github.com/user/{}.git", identifier3)], ), Tag::custom( TagKind::custom("relays"), vec!["wss://relay.example.com".to_string()], ), ]; let announcement3 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") .tags(tags3) .sign_with_keys(&keys3) .expect("Failed to sign announcement"); let client3 = Client::new(keys3.clone()); client3.add_relay(&url).await.expect("Failed to add relay"); client3.connect().await; tokio::time::sleep(Duration::from_millis(500)).await; client3 .send_event(&announcement3) .await .expect("Failed to send announcement"); tokio::time::sleep(Duration::from_millis(500)).await; // Verify first service announcement was accepted let repo_path1 = git_data_path.join(format!("{}/{}.git", npub1, identifier1)); assert!( repo_path1.exists(), "Repository should be created for first GRASP service (git.example.com)" ); // Verify second service announcement was accepted let repo_path2 = git_data_path.join(format!("{}/{}.git", npub2, identifier2)); assert!( repo_path2.exists(), "Repository should be created for second GRASP service (gitlab.example.org)" ); // Verify non-listed service announcement was rejected let repo_path3 = git_data_path.join(format!("{}/{}.git", npub3, identifier3)); assert!( !repo_path3.exists(), "Repository should NOT be created for non-listed service (github.com)" ); // Cleanup client1.disconnect().await; client2.disconnect().await; client3.disconnect().await; let _ = process.kill(); let _ = process.wait(); } /// Test that archive_read_only mode creates bare git repositories and syncs data /// via relay-to-relay sync (purgatory sync infrastructure). /// /// Scenario: /// 1. Start source relay with full repository (announcement + state + git data) /// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source /// 3. Archive relay syncs announcement and state events from source /// 4. State events trigger purgatory sync which fetches git data from source's clone URL /// 5. Verify bare repository is created and git data is synced /// 6. Verify git pushes are rejected (read-only mode) #[tokio::test] async fn test_archive_read_only_creates_bare_repo() { // 1. Start source relay let source_relay = TestRelay::start().await; let keys = Keys::generate(); let identifier = "archive-test-repo"; // Pre-allocate archive relay port so we can include it in announcement let archive_port = TestRelay::find_free_port(); let archive_domain = format!("127.0.0.1:{}", archive_port); // 2. Create test repository locally with deterministic commit let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); let npub = keys.public_key().to_bech32().expect("Failed to get npub"); // 3. Create and send announcement listing BOTH relays // This ensures the archive relay will accept the state event when it syncs let announcement = create_repo_announcement( &keys, &[&source_relay.domain(), &archive_domain], identifier, ); let source_client = Client::new(keys.clone()); source_client .add_relay(source_relay.url()) .await .expect("Failed to add source relay"); source_client.connect().await; // Wait for connection tokio::time::sleep(Duration::from_millis(500)).await; // Send announcement to source relay source_client .send_event(&announcement) .await .expect("Failed to send announcement to source"); tokio::time::sleep(Duration::from_millis(200)).await; // 4. Create and send state event let clone_urls = [ format!( "http://{}/{}/{}.git", source_relay.domain(), npub, identifier ), format!("http://{}/{}/{}.git", archive_domain, npub, identifier), ]; let relay_urls = [ source_relay.url().to_string(), format!("ws://{}", archive_domain), ]; let state_event = create_state_event( &keys, identifier, &[("main", &commit_hash)], &[], &[&clone_urls[0], &clone_urls[1]], &[&relay_urls[0], &relay_urls[1]], ) .expect("Failed to create state event"); let state_event_id = state_event.id; // Send state event to source relay (goes to purgatory - no git data yet) 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 // The state event in purgatory authorizes this push push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier) .expect("Push to source should succeed"); // After push, state event should be released from purgatory on source relay wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5)) .await .expect("State event should be served on source relay after push"); // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source let archive_relay = TestRelay::start_with_archive_and_sync( archive_port, Some(source_relay.url().to_string()), false, // negentropy enabled true, // archive_all true, // archive_read_only ) .await; // Wait for sync connection to establish wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5)) .await .expect("Sync connection should establish"); // 7. Wait for state event to be released on archive relay // The sync should: // a) Fetch the announcement and state event from source relay // b) Accept announcement (creates bare repo structure) - via archive mode // 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, Duration::from_secs(30), // Allow time for sync + git fetch ) .await; assert!( found.is_ok(), "State event should be served after sync fetches git data: {:?}", found.err() ); // 8. Verify bare repository was created let repo_path = archive_relay .git_data_path() .join(format!("{}/{}.git", npub, identifier)); assert!( repo_path.exists(), "Bare repository should be created at {:?} for archive announcement", repo_path ); // 9. Verify it's a bare repository (check for config file with bare = true) let config_path = repo_path.join("config"); assert!( config_path.exists(), "Git config should exist at {:?}", config_path ); let config_content = tokio::fs::read_to_string(&config_path) .await .expect("Should read git config"); assert!( config_content.contains("bare = true"), "Repository at {:?} should be bare (config should contain 'bare = true')", repo_path ); // 10. Verify refs are correct on archive relay let ref_correct = check_ref_at_commit( &archive_domain, &npub, identifier, "refs/heads/main", &commit_hash, ) .await .expect("Failed to check ref"); assert!(ref_correct, "main branch should point to correct commit"); // 11. Verify git pushes are rejected (read-only mode) // Create a new commit in the source repo tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content") .await .expect("Failed to write new file"); let output = tokio::process::Command::new("git") .args(["add", "."]) .current_dir(temp_dir.path()) .output() .await .expect("Failed to git add"); assert!(output.status.success()); let output = tokio::process::Command::new("git") .args(["commit", "-m", "New commit for push test"]) .current_dir(temp_dir.path()) .output() .await .expect("Failed to git commit"); assert!(output.status.success()); // Try to push to archive relay (should fail in read-only mode) let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier); let output = tokio::process::Command::new("git") .args(["push", &push_url, "main"]) .current_dir(temp_dir.path()) .output() .await .expect("Failed to run git push"); assert!( !output.status.success(), "Git push should be rejected in archive_read_only mode. stderr: {}", String::from_utf8_lossy(&output.stderr) ); // Cleanup source_client.disconnect().await; archive_relay.stop().await; source_relay.stop().await; }