From e7e61d1abfb3609c6818e6040294c6be19ba805f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 20:02:40 +0000 Subject: refactor: move archive_read_only test to archive_grasp_services and remove redundant test --- tests/archive_grasp_services.rs | 225 +++++++++++++++++++++- tests/archive_read_only.rs | 417 ---------------------------------------- 2 files changed, 224 insertions(+), 418 deletions(-) delete mode 100644 tests/archive_read_only.rs diff --git a/tests/archive_grasp_services.rs b/tests/archive_grasp_services.rs index a47fc55..9f13d2a 100644 --- a/tests/archive_grasp_services.rs +++ b/tests/archive_grasp_services.rs @@ -29,7 +29,11 @@ mod common; -use common::TestRelay; +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}; @@ -376,3 +380,222 @@ async fn test_archive_multiple_grasp_services() { 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; +} diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs deleted file mode 100644 index 069b3b7..0000000 --- a/tests/archive_read_only.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Archive Read-Only Mode Integration Tests -//! -//! Tests that verify archive_read_only mode behavior: -//! - Bare git repositories are created for announcements -//! - Git data is synced via relay-to-relay sync (purgatory sync) -//! - Git pushes are rejected (read-only mode) -//! -//! # Test Strategy -//! -//! These tests verify the GRASP-05 archive mode with read_only flag: -//! 1. Source relay has full repository (announcement + state events + git data) -//! 2. Archive relay syncs from source relay (relay-to-relay sync) -//! 3. State events trigger purgatory sync which fetches git data -//! 4. Git data is validated against Nostr state events -//! 5. Git pushes are rejected (read-only enforcement) -//! -//! # Security Model -//! -//! Archive mode uses the existing purgatory sync infrastructure to ensure: -//! - Git data is validated against Nostr state events -//! - "Naughty git servers" can't provide incorrect state -//! - Same security guarantees as normal relay operation -//! -//! # Running Tests -//! -//! ```bash -//! # Run all archive read-only tests -//! cargo test --test archive_read_only -//! -//! # Run specific test -//! cargo test --test archive_read_only test_archive_read_only_creates_bare_repo -//! -//! # With output for debugging -//! cargo test --test archive_read_only -- --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::time::Duration; - -/// 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; -} - -/// Test that archive mode proactively syncs state events and git data -/// when the source relay has state events available. -/// -/// With StateOnly sync now implemented, purgatory announcements subscribe -/// to state events from the relays listed in the announcement. This means -/// the archive relay will: -/// 1. Sync the announcement → purgatory → register as StateOnly in repo_sync_index -/// 2. Subscribe to state events (kind 30618) on source relay -/// 3. Receive the state event → purgatory sync triggered -/// 4. Fetch git data from source relay's clone URL -/// -/// This test verifies the full sync chain works end-to-end for archive mode. -#[tokio::test] -async fn test_archive_syncs_state_events_and_git_data_via_state_only_subscription() { - // 1. Start source relay - let source_relay = TestRelay::start().await; - let keys = Keys::generate(); - let identifier = "archive-state-only-sync-repo"; - - // Pre-allocate archive relay port - let archive_port = TestRelay::find_free_port(); - let archive_domain = format!("127.0.0.1:{}", archive_port); - - // 2. Create test repository locally - 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 - // The archive relay will subscribe to state events on BOTH listed relays - 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; - - tokio::time::sleep(Duration::from_millis(500)).await; - - // Send announcement to source relay (goes to purgatory) - 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 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"); - - let state_event_id = state_event.id; - - 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"); - - // Wait for state event to be promoted 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 - StateOnly subscription will proactively fetch state events - let archive_relay = TestRelay::start_with_archive_and_sync( - archive_port, - Some(source_relay.url().to_string()), - false, - true, - true, - ) - .await; - - // Wait for sync connection - 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 served on archive relay - // The StateOnly subscription fetches the state event from source relay, - // which then triggers purgatory sync and git data fetch. - 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 on archive after StateOnly subscription fetches it: {:?}", - 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 for archive announcement" - ); - - // 9. Verify git data was synced via the state event chain - let output = tokio::process::Command::new("git") - .args(["cat-file", "-t", &commit_hash]) - .current_dir(&repo_path) - .output() - .await; - - let commit_exists = output.map(|o| o.status.success()).unwrap_or(false); - - assert!( - commit_exists, - "Git data should be synced via StateOnly subscription → state event → git fetch chain" - ); - - // Cleanup - source_client.disconnect().await; - archive_relay.stop().await; - source_relay.stop().await; -} -- cgit v1.2.3