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:
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