upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-19 14:25:27 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-19 15:04:00 +0000
commit9372ad649b6c438b1e4645f1dbe95c0f648bb80d (patch)
treea2f95431711bde64713aeb72f3a7dcc65ffe58cc /tests
parent16833501a1004a5a661a729e4fd2dbcbeaecd1d5 (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')
-rw-r--r--tests/archive_read_only.rs368
-rw-r--r--tests/common/relay.rs92
2 files changed, 459 insertions, 1 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
37mod common;
38
39use 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};
44use nostr_sdk::prelude::*;
45use 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]
58async 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]
276async 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}
diff --git a/tests/common/relay.rs b/tests/common/relay.rs
index fb5d421..227849a 100644
--- a/tests/common/relay.rs
+++ b/tests/common/relay.rs
@@ -3,6 +3,7 @@
3//! Provides automatic relay lifecycle management for integration tests. 3//! Provides automatic relay lifecycle management for integration tests.
4 4
5use nostr_sdk::ToBech32; 5use nostr_sdk::ToBech32;
6use std::path::PathBuf;
6use std::process::{Child, Command, Stdio}; 7use std::process::{Child, Command, Stdio};
7use std::time::Duration; 8use std::time::Duration;
8use tokio::time::sleep; 9use tokio::time::sleep;
@@ -15,6 +16,11 @@ pub struct TestRelay {
15 process: Child, 16 process: Child,
16 url: String, 17 url: String,
17 port: u16, 18 port: u16,
19 /// Temporary directory for git repositories
20 /// Kept alive for the lifetime of the relay
21 _git_data_dir: tempfile::TempDir,
22 /// Path to git data directory (for test assertions)
23 git_data_path: PathBuf,
18} 24}
19 25
20impl TestRelay { 26impl TestRelay {
@@ -98,6 +104,37 @@ impl TestRelay {
98 Self::start_with_full_options(Self::find_free_port(), bootstrap_relay_url, true).await 104 Self::start_with_full_options(Self::find_free_port(), bootstrap_relay_url, true).await
99 } 105 }
100 106
107 /// Start relay with archive configuration
108 ///
109 /// This is useful for testing GRASP-05 archive mode behavior.
110 ///
111 /// # Arguments
112 /// * `archive_all` - Accept all repository announcements (GRASP-05)
113 /// * `archive_read_only` - Reject git pushes (read-only archive mode)
114 ///
115 /// # Example
116 ///
117 /// ```no_run
118 /// use common::TestRelay;
119 ///
120 /// #[tokio::test]
121 /// async fn test_archive_mode() {
122 /// let relay = TestRelay::start_with_archive_config(true, true).await;
123 /// // ... test archive behavior ...
124 /// relay.stop().await;
125 /// }
126 /// ```
127 pub async fn start_with_archive_config(archive_all: bool, archive_read_only: bool) -> Self {
128 Self::start_with_archive_and_sync(
129 Self::find_free_port(),
130 None,
131 false,
132 archive_all,
133 archive_read_only,
134 )
135 .await
136 }
137
101 /// Start relay with options (internal, maintains backward compatibility) 138 /// Start relay with options (internal, maintains backward compatibility)
102 async fn start_with_options(port: u16, bootstrap_relay_url: Option<String>) -> Self { 139 async fn start_with_options(port: u16, bootstrap_relay_url: Option<String>) -> Self {
103 Self::start_with_full_options(port, bootstrap_relay_url, false).await 140 Self::start_with_full_options(port, bootstrap_relay_url, false).await
@@ -109,6 +146,34 @@ impl TestRelay {
109 bootstrap_relay_url: Option<String>, 146 bootstrap_relay_url: Option<String>,
110 disable_negentropy: bool, 147 disable_negentropy: bool,
111 ) -> Self { 148 ) -> Self {
149 Self::start_with_archive_and_sync(
150 port,
151 bootstrap_relay_url,
152 disable_negentropy,
153 false,
154 false,
155 )
156 .await
157 }
158
159 /// Start relay with all options including archive configuration and sync
160 ///
161 /// This is the most flexible method for starting a test relay with all options.
162 /// Use this when you need both archive mode AND sync from a bootstrap relay.
163 ///
164 /// # Arguments
165 /// * `port` - Port to bind to
166 /// * `bootstrap_relay_url` - URL of relay to sync from (optional)
167 /// * `disable_negentropy` - Whether to disable NIP-77 negentropy sync
168 /// * `archive_all` - Accept all repository announcements (GRASP-05)
169 /// * `archive_read_only` - Reject git pushes (read-only archive mode)
170 pub async fn start_with_archive_and_sync(
171 port: u16,
172 bootstrap_relay_url: Option<String>,
173 disable_negentropy: bool,
174 archive_all: bool,
175 archive_read_only: bool,
176 ) -> Self {
112 let bind_address = format!("127.0.0.1:{}", port); 177 let bind_address = format!("127.0.0.1:{}", port);
113 let url = format!("ws://127.0.0.1:{}", port); 178 let url = format!("ws://127.0.0.1:{}", port);
114 179
@@ -161,9 +226,26 @@ impl TestRelay {
161 cmd.env("NGIT_SYNC_DISABLE_NEGENTROPY", "true"); 226 cmd.env("NGIT_SYNC_DISABLE_NEGENTROPY", "true");
162 } 227 }
163 228
229 // Add archive configuration if requested
230 if archive_all {
231 cmd.env("NGIT_ARCHIVE_ALL", "true");
232 }
233 if archive_read_only {
234 cmd.env("NGIT_ARCHIVE_READ_ONLY", "true");
235 }
236
164 let process = cmd.spawn().expect("Failed to start relay process"); 237 let process = cmd.spawn().expect("Failed to start relay process");
165 238
166 let relay = Self { process, url, port }; 239 // Store git data path for test assertions
240 let git_data_path = git_data_dir.path().to_path_buf();
241
242 let relay = Self {
243 process,
244 url,
245 port,
246 _git_data_dir: git_data_dir,
247 git_data_path,
248 };
167 249
168 // Wait for relay to be ready 250 // Wait for relay to be ready
169 relay.wait_for_ready().await; 251 relay.wait_for_ready().await;
@@ -181,6 +263,14 @@ impl TestRelay {
181 format!("127.0.0.1:{}", self.port) 263 format!("127.0.0.1:{}", self.port)
182 } 264 }
183 265
266 /// Get the git data directory path
267 ///
268 /// This is useful for test assertions that need to verify
269 /// git repositories were created correctly.
270 pub fn git_data_path(&self) -> &PathBuf {
271 &self.git_data_path
272 }
273
184 /// Wait for the relay to be ready to accept connections 274 /// Wait for the relay to be ready to accept connections
185 async fn wait_for_ready(&self) { 275 async fn wait_for_ready(&self) {
186 let max_attempts = 50; // 5 seconds total 276 let max_attempts = 50; // 5 seconds total