diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-19 14:25:27 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-19 15:04:00 +0000 |
| commit | 9372ad649b6c438b1e4645f1dbe95c0f648bb80d (patch) | |
| tree | a2f95431711bde64713aeb72f3a7dcc65ffe58cc /tests/archive_read_only.rs | |
| parent | 16833501a1004a5a661a729e4fd2dbcbeaecd1d5 (diff) | |
fix: archive_read_only creates bare repos for archived announcements
Combined Accept and AcceptArchive match arms in builder.rs to ensure
bare repositories are created for both cases. Previously AcceptArchive
had duplicate code that didn't call ensure_bare_repository().
Also includes:
- Config fix: effective_git_data_path() respects explicit paths with memory backend
- TestRelay: Added git_data_path() and archive config support for testing
- Integration tests for archive_read_only behavior
Diffstat (limited to 'tests/archive_read_only.rs')
| -rw-r--r-- | tests/archive_read_only.rs | 368 |
1 files changed, 368 insertions, 0 deletions
diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs new file mode 100644 index 0000000..be6959b --- /dev/null +++ b/tests/archive_read_only.rs | |||
| @@ -0,0 +1,368 @@ | |||
| 1 | //! Archive Read-Only Mode Integration Tests | ||
| 2 | //! | ||
| 3 | //! Tests that verify archive_read_only mode behavior: | ||
| 4 | //! - Bare git repositories are created for announcements | ||
| 5 | //! - Git data is synced via relay-to-relay sync (purgatory sync) | ||
| 6 | //! - Git pushes are rejected (read-only mode) | ||
| 7 | //! | ||
| 8 | //! # Test Strategy | ||
| 9 | //! | ||
| 10 | //! These tests verify the GRASP-05 archive mode with read_only flag: | ||
| 11 | //! 1. Source relay has full repository (announcement + state events + git data) | ||
| 12 | //! 2. Archive relay syncs from source relay (relay-to-relay sync) | ||
| 13 | //! 3. State events trigger purgatory sync which fetches git data | ||
| 14 | //! 4. Git data is validated against Nostr state events | ||
| 15 | //! 5. Git pushes are rejected (read-only enforcement) | ||
| 16 | //! | ||
| 17 | //! # Security Model | ||
| 18 | //! | ||
| 19 | //! Archive mode uses the existing purgatory sync infrastructure to ensure: | ||
| 20 | //! - Git data is validated against Nostr state events | ||
| 21 | //! - "Naughty git servers" can't provide incorrect state | ||
| 22 | //! - Same security guarantees as normal relay operation | ||
| 23 | //! | ||
| 24 | //! # Running Tests | ||
| 25 | //! | ||
| 26 | //! ```bash | ||
| 27 | //! # Run all archive read-only tests | ||
| 28 | //! cargo test --test archive_read_only | ||
| 29 | //! | ||
| 30 | //! # Run specific test | ||
| 31 | //! cargo test --test archive_read_only test_archive_read_only_creates_bare_repo | ||
| 32 | //! | ||
| 33 | //! # With output for debugging | ||
| 34 | //! cargo test --test archive_read_only -- --nocapture | ||
| 35 | //! ``` | ||
| 36 | |||
| 37 | mod common; | ||
| 38 | |||
| 39 | use common::{ | ||
| 40 | check_ref_at_commit, create_repo_announcement, create_state_event, | ||
| 41 | create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection, | ||
| 42 | CommitVariant, TestRelay, | ||
| 43 | }; | ||
| 44 | use nostr_sdk::prelude::*; | ||
| 45 | use std::time::Duration; | ||
| 46 | |||
| 47 | /// Test that archive_read_only mode creates bare git repositories and syncs data | ||
| 48 | /// via relay-to-relay sync (purgatory sync infrastructure). | ||
| 49 | /// | ||
| 50 | /// Scenario: | ||
| 51 | /// 1. Start source relay with full repository (announcement + state + git data) | ||
| 52 | /// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source | ||
| 53 | /// 3. Archive relay syncs announcement and state events from source | ||
| 54 | /// 4. State events trigger purgatory sync which fetches git data from source's clone URL | ||
| 55 | /// 5. Verify bare repository is created and git data is synced | ||
| 56 | /// 6. Verify git pushes are rejected (read-only mode) | ||
| 57 | #[tokio::test] | ||
| 58 | async fn test_archive_read_only_creates_bare_repo() { | ||
| 59 | // 1. Start source relay | ||
| 60 | let source_relay = TestRelay::start().await; | ||
| 61 | let keys = Keys::generate(); | ||
| 62 | let identifier = "archive-test-repo"; | ||
| 63 | |||
| 64 | // Pre-allocate archive relay port so we can include it in announcement | ||
| 65 | let archive_port = TestRelay::find_free_port(); | ||
| 66 | let archive_domain = format!("127.0.0.1:{}", archive_port); | ||
| 67 | |||
| 68 | // 2. Create test repository locally with deterministic commit | ||
| 69 | let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); | ||
| 70 | let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 71 | .expect("Failed to create test repo"); | ||
| 72 | |||
| 73 | let npub = keys.public_key().to_bech32().expect("Failed to get npub"); | ||
| 74 | |||
| 75 | // 3. Create and send announcement listing BOTH relays | ||
| 76 | // This ensures the archive relay will accept the state event when it syncs | ||
| 77 | let announcement = create_repo_announcement( | ||
| 78 | &keys, | ||
| 79 | &[&source_relay.domain(), &archive_domain], | ||
| 80 | identifier, | ||
| 81 | ); | ||
| 82 | |||
| 83 | let source_client = Client::new(keys.clone()); | ||
| 84 | source_client | ||
| 85 | .add_relay(source_relay.url()) | ||
| 86 | .await | ||
| 87 | .expect("Failed to add source relay"); | ||
| 88 | source_client.connect().await; | ||
| 89 | |||
| 90 | // Wait for connection | ||
| 91 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 92 | |||
| 93 | // Send announcement to source relay | ||
| 94 | source_client | ||
| 95 | .send_event(&announcement) | ||
| 96 | .await | ||
| 97 | .expect("Failed to send announcement to source"); | ||
| 98 | |||
| 99 | tokio::time::sleep(Duration::from_millis(200)).await; | ||
| 100 | |||
| 101 | // 4. Create and send state event | ||
| 102 | let clone_urls = [ | ||
| 103 | format!( | ||
| 104 | "http://{}/{}/{}.git", | ||
| 105 | source_relay.domain(), | ||
| 106 | npub, | ||
| 107 | identifier | ||
| 108 | ), | ||
| 109 | format!("http://{}/{}/{}.git", archive_domain, npub, identifier), | ||
| 110 | ]; | ||
| 111 | let relay_urls = [ | ||
| 112 | source_relay.url().to_string(), | ||
| 113 | format!("ws://{}", archive_domain), | ||
| 114 | ]; | ||
| 115 | |||
| 116 | let state_event = create_state_event( | ||
| 117 | &keys, | ||
| 118 | identifier, | ||
| 119 | &[("main", &commit_hash)], | ||
| 120 | &[], | ||
| 121 | &[&clone_urls[0], &clone_urls[1]], | ||
| 122 | &[&relay_urls[0], &relay_urls[1]], | ||
| 123 | ) | ||
| 124 | .expect("Failed to create state event"); | ||
| 125 | |||
| 126 | let state_event_id = state_event.id; | ||
| 127 | |||
| 128 | // Send state event to source relay (goes to purgatory - no git data yet) | ||
| 129 | source_client | ||
| 130 | .send_event(&state_event) | ||
| 131 | .await | ||
| 132 | .expect("Failed to send state event to source"); | ||
| 133 | |||
| 134 | tokio::time::sleep(Duration::from_millis(200)).await; | ||
| 135 | |||
| 136 | // 5. Push git data to source relay | ||
| 137 | // The state event in purgatory authorizes this push | ||
| 138 | push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier) | ||
| 139 | .expect("Push to source should succeed"); | ||
| 140 | |||
| 141 | // After push, state event should be released from purgatory on source relay | ||
| 142 | wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5)) | ||
| 143 | .await | ||
| 144 | .expect("State event should be served on source relay after push"); | ||
| 145 | |||
| 146 | // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source | ||
| 147 | let archive_relay = TestRelay::start_with_archive_and_sync( | ||
| 148 | archive_port, | ||
| 149 | Some(source_relay.url().to_string()), | ||
| 150 | false, // negentropy enabled | ||
| 151 | true, // archive_all | ||
| 152 | true, // archive_read_only | ||
| 153 | ) | ||
| 154 | .await; | ||
| 155 | |||
| 156 | // Wait for sync connection to establish | ||
| 157 | wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5)) | ||
| 158 | .await | ||
| 159 | .expect("Sync connection should establish"); | ||
| 160 | |||
| 161 | // 7. Wait for state event to be released on archive relay | ||
| 162 | // The sync should: | ||
| 163 | // a) Fetch the announcement and state event from source relay | ||
| 164 | // b) Accept announcement (creates bare repo structure) - via archive mode | ||
| 165 | // c) Put state event in purgatory (git data missing on archive relay) | ||
| 166 | // d) Fetch git data from source relay's clone URL | ||
| 167 | // e) Release the state event from purgatory | ||
| 168 | let found = wait_for_event_served( | ||
| 169 | archive_relay.url(), | ||
| 170 | &state_event_id, | ||
| 171 | Duration::from_secs(30), // Allow time for sync + git fetch | ||
| 172 | ) | ||
| 173 | .await; | ||
| 174 | |||
| 175 | assert!( | ||
| 176 | found.is_ok(), | ||
| 177 | "State event should be served after sync fetches git data: {:?}", | ||
| 178 | found.err() | ||
| 179 | ); | ||
| 180 | |||
| 181 | // 8. Verify bare repository was created | ||
| 182 | let repo_path = archive_relay | ||
| 183 | .git_data_path() | ||
| 184 | .join(format!("{}/{}.git", npub, identifier)); | ||
| 185 | |||
| 186 | assert!( | ||
| 187 | repo_path.exists(), | ||
| 188 | "Bare repository should be created at {:?} for archive announcement", | ||
| 189 | repo_path | ||
| 190 | ); | ||
| 191 | |||
| 192 | // 9. Verify it's a bare repository (check for config file with bare = true) | ||
| 193 | let config_path = repo_path.join("config"); | ||
| 194 | assert!( | ||
| 195 | config_path.exists(), | ||
| 196 | "Git config should exist at {:?}", | ||
| 197 | config_path | ||
| 198 | ); | ||
| 199 | |||
| 200 | let config_content = tokio::fs::read_to_string(&config_path) | ||
| 201 | .await | ||
| 202 | .expect("Should read git config"); | ||
| 203 | assert!( | ||
| 204 | config_content.contains("bare = true"), | ||
| 205 | "Repository at {:?} should be bare (config should contain 'bare = true')", | ||
| 206 | repo_path | ||
| 207 | ); | ||
| 208 | |||
| 209 | // 10. Verify refs are correct on archive relay | ||
| 210 | let ref_correct = check_ref_at_commit( | ||
| 211 | &archive_domain, | ||
| 212 | &npub, | ||
| 213 | identifier, | ||
| 214 | "refs/heads/main", | ||
| 215 | &commit_hash, | ||
| 216 | ) | ||
| 217 | .await | ||
| 218 | .expect("Failed to check ref"); | ||
| 219 | |||
| 220 | assert!(ref_correct, "main branch should point to correct commit"); | ||
| 221 | |||
| 222 | // 11. Verify git pushes are rejected (read-only mode) | ||
| 223 | // Create a new commit in the source repo | ||
| 224 | tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content") | ||
| 225 | .await | ||
| 226 | .expect("Failed to write new file"); | ||
| 227 | |||
| 228 | let output = tokio::process::Command::new("git") | ||
| 229 | .args(["add", "."]) | ||
| 230 | .current_dir(temp_dir.path()) | ||
| 231 | .output() | ||
| 232 | .await | ||
| 233 | .expect("Failed to git add"); | ||
| 234 | assert!(output.status.success()); | ||
| 235 | |||
| 236 | let output = tokio::process::Command::new("git") | ||
| 237 | .args(["commit", "-m", "New commit for push test"]) | ||
| 238 | .current_dir(temp_dir.path()) | ||
| 239 | .output() | ||
| 240 | .await | ||
| 241 | .expect("Failed to git commit"); | ||
| 242 | assert!(output.status.success()); | ||
| 243 | |||
| 244 | // Try to push to archive relay (should fail in read-only mode) | ||
| 245 | let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier); | ||
| 246 | let output = tokio::process::Command::new("git") | ||
| 247 | .args(["push", &push_url, "main"]) | ||
| 248 | .current_dir(temp_dir.path()) | ||
| 249 | .output() | ||
| 250 | .await | ||
| 251 | .expect("Failed to run git push"); | ||
| 252 | |||
| 253 | assert!( | ||
| 254 | !output.status.success(), | ||
| 255 | "Git push should be rejected in archive_read_only mode. stderr: {}", | ||
| 256 | String::from_utf8_lossy(&output.stderr) | ||
| 257 | ); | ||
| 258 | |||
| 259 | // Cleanup | ||
| 260 | source_client.disconnect().await; | ||
| 261 | archive_relay.stop().await; | ||
| 262 | source_relay.stop().await; | ||
| 263 | } | ||
| 264 | |||
| 265 | /// Test that archive mode without state events does NOT sync git data. | ||
| 266 | /// | ||
| 267 | /// This verifies the security model: archive mode only syncs git data | ||
| 268 | /// when there are state events to validate against. | ||
| 269 | /// | ||
| 270 | /// Scenario: | ||
| 271 | /// 1. Start source relay with announcement only (no state events) | ||
| 272 | /// 2. Start archive relay syncing from source | ||
| 273 | /// 3. Archive relay syncs announcement (creates bare repo) | ||
| 274 | /// 4. Verify git data is NOT synced (no state events to trigger purgatory sync) | ||
| 275 | #[tokio::test] | ||
| 276 | async fn test_archive_without_state_events_does_not_sync_git() { | ||
| 277 | // 1. Start source relay | ||
| 278 | let source_relay = TestRelay::start().await; | ||
| 279 | let keys = Keys::generate(); | ||
| 280 | let identifier = "archive-no-state-repo"; | ||
| 281 | |||
| 282 | // Pre-allocate archive relay port | ||
| 283 | let archive_port = TestRelay::find_free_port(); | ||
| 284 | let archive_domain = format!("127.0.0.1:{}", archive_port); | ||
| 285 | |||
| 286 | // 2. Create test repository locally | ||
| 287 | let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); | ||
| 288 | let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 289 | .expect("Failed to create test repo"); | ||
| 290 | |||
| 291 | let npub = keys.public_key().to_bech32().expect("Failed to get npub"); | ||
| 292 | |||
| 293 | // 3. Create and send announcement listing BOTH relays (but NO state event) | ||
| 294 | let announcement = create_repo_announcement( | ||
| 295 | &keys, | ||
| 296 | &[&source_relay.domain(), &archive_domain], | ||
| 297 | identifier, | ||
| 298 | ); | ||
| 299 | |||
| 300 | let source_client = Client::new(keys.clone()); | ||
| 301 | source_client | ||
| 302 | .add_relay(source_relay.url()) | ||
| 303 | .await | ||
| 304 | .expect("Failed to add source relay"); | ||
| 305 | source_client.connect().await; | ||
| 306 | |||
| 307 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 308 | |||
| 309 | // Send announcement to source relay | ||
| 310 | source_client | ||
| 311 | .send_event(&announcement) | ||
| 312 | .await | ||
| 313 | .expect("Failed to send announcement to source"); | ||
| 314 | |||
| 315 | tokio::time::sleep(Duration::from_millis(200)).await; | ||
| 316 | |||
| 317 | // 4. Push git data to source relay (but no state event to authorize it) | ||
| 318 | // This push will fail because there's no state event in purgatory | ||
| 319 | // That's expected - we're testing that archive mode doesn't blindly fetch git data | ||
| 320 | |||
| 321 | // 5. Start archive relay | ||
| 322 | let archive_relay = TestRelay::start_with_archive_and_sync( | ||
| 323 | archive_port, | ||
| 324 | Some(source_relay.url().to_string()), | ||
| 325 | false, | ||
| 326 | true, | ||
| 327 | true, | ||
| 328 | ) | ||
| 329 | .await; | ||
| 330 | |||
| 331 | // Wait for sync | ||
| 332 | wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5)) | ||
| 333 | .await | ||
| 334 | .expect("Sync connection should establish"); | ||
| 335 | |||
| 336 | // Give time for any potential git sync to happen | ||
| 337 | tokio::time::sleep(Duration::from_secs(3)).await; | ||
| 338 | |||
| 339 | // 6. Verify bare repository was created (announcement was accepted) | ||
| 340 | let repo_path = archive_relay | ||
| 341 | .git_data_path() | ||
| 342 | .join(format!("{}/{}.git", npub, identifier)); | ||
| 343 | |||
| 344 | assert!( | ||
| 345 | repo_path.exists(), | ||
| 346 | "Bare repository should be created for archive announcement" | ||
| 347 | ); | ||
| 348 | |||
| 349 | // 7. Verify git data was NOT synced (no state events to trigger purgatory sync) | ||
| 350 | // Check that the commit does NOT exist in the archive relay's repo | ||
| 351 | let output = tokio::process::Command::new("git") | ||
| 352 | .args(["cat-file", "-t", &commit_hash]) | ||
| 353 | .current_dir(&repo_path) | ||
| 354 | .output() | ||
| 355 | .await; | ||
| 356 | |||
| 357 | let commit_exists = output.map(|o| o.status.success()).unwrap_or(false); | ||
| 358 | |||
| 359 | assert!( | ||
| 360 | !commit_exists, | ||
| 361 | "Git data should NOT be synced without state events (security: validates against Nostr state)" | ||
| 362 | ); | ||
| 363 | |||
| 364 | // Cleanup | ||
| 365 | source_client.disconnect().await; | ||
| 366 | archive_relay.stop().await; | ||
| 367 | source_relay.stop().await; | ||
| 368 | } | ||