upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-26 03:53:31 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-26 03:53:31 +0000
commit963b2971ec2f43b1c2f669a969c294fc1d291d3b (patch)
tree9495d5bd350ebf5123a9c72b9da3367b17e5534f /grasp-audit/src
parent75f3da90edb66b81dbb6ed9806155f6bd7925fe1 (diff)
add cors support
Diffstat (limited to 'grasp-audit/src')
-rw-r--r--grasp-audit/src/specs/grasp01/cors.rs507
-rw-r--r--grasp-audit/src/specs/grasp01/mod.rs2
2 files changed, 509 insertions, 0 deletions
diff --git a/grasp-audit/src/specs/grasp01/cors.rs b/grasp-audit/src/specs/grasp01/cors.rs
new file mode 100644
index 0000000..4e0513e
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/cors.rs
@@ -0,0 +1,507 @@
1//! GRASP-01 CORS Tests
2//!
3//! Tests for GRASP-01 CORS requirements (lines 40-47 of ../grasp/01.md)
4//!
5//! These tests validate that a GRASP-01 compliant relay implements CORS correctly:
6//! - Sets `Access-Control-Allow-Origin: *` on ALL responses
7//! - Sets `Access-Control-Allow-Methods: GET, POST` on ALL responses
8//! - Sets `Access-Control-Allow-Headers: Content-Type` on ALL responses
9//! - Responds to OPTIONS requests with 204 No Content
10//!
11//! ## Running Tests
12//!
13//! ```bash
14//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
15//! ```
16
17use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
18use nostr_sdk::prelude::*;
19use std::path::Path;
20
21pub struct CorsTests;
22
23impl CorsTests {
24 /// Run all CORS tests
25 pub async fn run_all(client: &AuditClient, relay_domain: &str) -> AuditResult {
26 let mut results = AuditResult::new("GRASP-01 CORS Tests");
27
28 // CORS tests against Git HTTP endpoints
29 results.add(Self::test_cors_allow_origin(client, relay_domain).await);
30 results.add(Self::test_cors_allow_methods(client, relay_domain).await);
31 results.add(Self::test_cors_allow_headers(client, relay_domain).await);
32 results.add(Self::test_cors_options_preflight(client, relay_domain).await);
33
34 results
35 }
36
37 // =========================================================================
38 // CORS Tests
39 // =========================================================================
40
41 /// Test: Access-Control-Allow-Origin header on all responses
42 ///
43 /// Spec: Line 44 of ../grasp/01.md
44 /// Requirement: Set `Access-Control-Allow-Origin: *` on ALL responses
45 pub async fn test_cors_allow_origin(
46 _client: &AuditClient,
47 relay_domain: &str,
48 ) -> TestResult {
49 TestResult::new(
50 "cors_allow_origin",
51 "GRASP-01:git-http:cors:44",
52 "Access-Control-Allow-Origin: * on all responses",
53 )
54 .run(|| {
55 let relay_domain = relay_domain.to_string();
56 async move {
57 // Test multiple endpoints to verify "ALL responses" requirement
58 let http_client = reqwest::Client::new();
59
60 // 1. Test root endpoint
61 let root_url = format!("http://{}/", relay_domain);
62 let response = http_client
63 .get(&root_url)
64 .send()
65 .await
66 .map_err(|e| format!("Failed to GET root: {}", e))?;
67
68 check_cors_allow_origin(&response, "root endpoint")?;
69
70 // 2. Test a non-existent repo path (still should have CORS headers)
71 let repo_url = format!(
72 "http://{}/npub1test/nonexistent.git/info/refs?service=git-upload-pack",
73 relay_domain
74 );
75 let response = http_client
76 .get(&repo_url)
77 .send()
78 .await
79 .map_err(|e| format!("Failed to GET repo endpoint: {}", e))?;
80
81 // Even 404 responses must have CORS headers
82 check_cors_allow_origin(&response, "git-upload-pack endpoint (even if 404)")?;
83
84 Ok(())
85 }
86 })
87 .await
88 }
89
90 /// Test: Access-Control-Allow-Methods header on all responses
91 ///
92 /// Spec: Line 45 of ../grasp/01.md
93 /// Requirement: Set `Access-Control-Allow-Methods: GET, POST` on ALL responses
94 pub async fn test_cors_allow_methods(
95 _client: &AuditClient,
96 relay_domain: &str,
97 ) -> TestResult {
98 TestResult::new(
99 "cors_allow_methods",
100 "GRASP-01:git-http:cors:45",
101 "Access-Control-Allow-Methods: GET, POST on all responses",
102 )
103 .run(|| {
104 let relay_domain = relay_domain.to_string();
105 async move {
106 let http_client = reqwest::Client::new();
107
108 // Test root endpoint
109 let root_url = format!("http://{}/", relay_domain);
110 let response = http_client
111 .get(&root_url)
112 .send()
113 .await
114 .map_err(|e| format!("Failed to GET root: {}", e))?;
115
116 check_cors_allow_methods(&response, "root endpoint")?;
117
118 // Test a repo path
119 let repo_url = format!(
120 "http://{}/npub1test/nonexistent.git/info/refs?service=git-upload-pack",
121 relay_domain
122 );
123 let response = http_client
124 .get(&repo_url)
125 .send()
126 .await
127 .map_err(|e| format!("Failed to GET repo endpoint: {}", e))?;
128
129 check_cors_allow_methods(&response, "git-upload-pack endpoint")?;
130
131 Ok(())
132 }
133 })
134 .await
135 }
136
137 /// Test: Access-Control-Allow-Headers header on all responses
138 ///
139 /// Spec: Line 46 of ../grasp/01.md
140 /// Requirement: Set `Access-Control-Allow-Headers: Content-Type` on ALL responses
141 pub async fn test_cors_allow_headers(
142 _client: &AuditClient,
143 relay_domain: &str,
144 ) -> TestResult {
145 TestResult::new(
146 "cors_allow_headers",
147 "GRASP-01:git-http:cors:46",
148 "Access-Control-Allow-Headers: Content-Type on all responses",
149 )
150 .run(|| {
151 let relay_domain = relay_domain.to_string();
152 async move {
153 let http_client = reqwest::Client::new();
154
155 // Test root endpoint
156 let root_url = format!("http://{}/", relay_domain);
157 let response = http_client
158 .get(&root_url)
159 .send()
160 .await
161 .map_err(|e| format!("Failed to GET root: {}", e))?;
162
163 check_cors_allow_headers(&response, "root endpoint")?;
164
165 // Test a repo path
166 let repo_url = format!(
167 "http://{}/npub1test/nonexistent.git/info/refs?service=git-upload-pack",
168 relay_domain
169 );
170 let response = http_client
171 .get(&repo_url)
172 .send()
173 .await
174 .map_err(|e| format!("Failed to GET repo endpoint: {}", e))?;
175
176 check_cors_allow_headers(&response, "git-upload-pack endpoint")?;
177
178 Ok(())
179 }
180 })
181 .await
182 }
183
184 /// Test: OPTIONS preflight requests return 204 No Content
185 ///
186 /// Spec: Line 47 of ../grasp/01.md
187 /// Requirement: Respond to OPTIONS requests with 204 No Content
188 pub async fn test_cors_options_preflight(
189 _client: &AuditClient,
190 relay_domain: &str,
191 ) -> TestResult {
192 TestResult::new(
193 "cors_options_preflight",
194 "GRASP-01:git-http:cors:47",
195 "OPTIONS requests return 204 No Content",
196 )
197 .run(|| {
198 let relay_domain = relay_domain.to_string();
199 async move {
200 let http_client = reqwest::Client::new();
201
202 // 1. Test OPTIONS on root endpoint
203 let root_url = format!("http://{}/", relay_domain);
204 let response = http_client
205 .request(reqwest::Method::OPTIONS, &root_url)
206 .header("Origin", "https://example.com")
207 .header("Access-Control-Request-Method", "POST")
208 .send()
209 .await
210 .map_err(|e| format!("Failed to OPTIONS root: {}", e))?;
211
212 check_options_response(&response, "root endpoint")?;
213
214 // 2. Test OPTIONS on git-upload-pack endpoint
215 let repo_url = format!(
216 "http://{}/npub1test/test.git/git-upload-pack",
217 relay_domain
218 );
219 let response = http_client
220 .request(reqwest::Method::OPTIONS, &repo_url)
221 .header("Origin", "https://example.com")
222 .header("Access-Control-Request-Method", "POST")
223 .send()
224 .await
225 .map_err(|e| format!("Failed to OPTIONS git-upload-pack: {}", e))?;
226
227 check_options_response(&response, "git-upload-pack endpoint")?;
228
229 // 3. Test OPTIONS on info/refs endpoint
230 let refs_url = format!(
231 "http://{}/npub1test/test.git/info/refs",
232 relay_domain
233 );
234 let response = http_client
235 .request(reqwest::Method::OPTIONS, &refs_url)
236 .header("Origin", "https://example.com")
237 .header("Access-Control-Request-Method", "GET")
238 .send()
239 .await
240 .map_err(|e| format!("Failed to OPTIONS info/refs: {}", e))?;
241
242 check_options_response(&response, "info/refs endpoint")?;
243
244 Ok(())
245 }
246 })
247 .await
248 }
249
250 // =========================================================================
251 // Integration test methods for use from external test files
252 // These match the pattern used by GitCloneTests
253 // =========================================================================
254
255 /// Integration test: CORS Allow-Origin header with repository creation
256 ///
257 /// For integration tests that want to test against real repositories
258 pub async fn test_cors_on_real_repo(
259 client: &AuditClient,
260 _git_data_dir: &Path,
261 relay_domain: &str,
262 ) -> TestResult {
263 let test_name = "test_cors_on_real_repo";
264 let ctx = TestContext::new(client);
265
266 // Create repository announcement to get a real repo path
267 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
268 Ok(r) => r,
269 Err(e) => {
270 return TestResult::new(
271 test_name,
272 "GRASP-01",
273 "CORS headers on real repository endpoint",
274 )
275 .fail(&format!("Failed to create repo fixture: {}", e))
276 }
277 };
278
279 // Wait for repository creation
280 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
281
282 // Extract repo identifier and npub
283 let repo_id = match repo
284 .tags
285 .iter()
286 .find(|t| t.kind() == TagKind::d())
287 .and_then(|t| t.content())
288 {
289 Some(id) => id.to_string(),
290 None => {
291 return TestResult::new(
292 test_name,
293 "GRASP-01",
294 "CORS headers on real repository endpoint",
295 )
296 .fail("Repository announcement missing d tag")
297 }
298 };
299
300 let npub = match repo.pubkey.to_bech32() {
301 Ok(n) => n,
302 Err(e) => {
303 return TestResult::new(
304 test_name,
305 "GRASP-01",
306 "CORS headers on real repository endpoint",
307 )
308 .fail(&format!("Failed to convert pubkey to npub: {}", e))
309 }
310 };
311
312 // Test CORS on real repo endpoint
313 let http_client = reqwest::Client::new();
314 let info_refs_url = format!(
315 "http://{}/{}/{}.git/info/refs?service=git-upload-pack",
316 relay_domain, npub, repo_id
317 );
318
319 let response = match http_client.get(&info_refs_url).send().await {
320 Ok(r) => r,
321 Err(e) => {
322 return TestResult::new(
323 test_name,
324 "GRASP-01",
325 "CORS headers on real repository endpoint",
326 )
327 .fail(&format!("Failed to GET info/refs: {}", e))
328 }
329 };
330
331 // Check all CORS headers
332 if let Err(e) = check_cors_allow_origin(&response, "info/refs") {
333 return TestResult::new(
334 test_name,
335 "GRASP-01",
336 "CORS headers on real repository endpoint",
337 )
338 .fail(&e);
339 }
340
341 if let Err(e) = check_cors_allow_methods(&response, "info/refs") {
342 return TestResult::new(
343 test_name,
344 "GRASP-01",
345 "CORS headers on real repository endpoint",
346 )
347 .fail(&e);
348 }
349
350 if let Err(e) = check_cors_allow_headers(&response, "info/refs") {
351 return TestResult::new(
352 test_name,
353 "GRASP-01",
354 "CORS headers on real repository endpoint",
355 )
356 .fail(&e);
357 }
358
359 TestResult::new(
360 test_name,
361 "GRASP-01",
362 "CORS headers on real repository endpoint",
363 )
364 .pass()
365 }
366}
367
368// =========================================================================
369// Helper functions
370// =========================================================================
371
372/// Check Access-Control-Allow-Origin header
373fn check_cors_allow_origin(response: &reqwest::Response, context: &str) -> Result<(), String> {
374 let header = response
375 .headers()
376 .get("Access-Control-Allow-Origin")
377 .ok_or_else(|| format!("Missing Access-Control-Allow-Origin header on {}", context))?;
378
379 let value = header
380 .to_str()
381 .map_err(|e| format!("Invalid Access-Control-Allow-Origin header value: {}", e))?;
382
383 if value != "*" {
384 return Err(format!(
385 "Expected Access-Control-Allow-Origin: *, got: '{}' on {}",
386 value, context
387 ));
388 }
389
390 Ok(())
391}
392
393/// Check Access-Control-Allow-Methods header
394fn check_cors_allow_methods(response: &reqwest::Response, context: &str) -> Result<(), String> {
395 let header = response
396 .headers()
397 .get("Access-Control-Allow-Methods")
398 .ok_or_else(|| format!("Missing Access-Control-Allow-Methods header on {}", context))?;
399
400 let value = header
401 .to_str()
402 .map_err(|e| format!("Invalid Access-Control-Allow-Methods header value: {}", e))?;
403
404 // The header should contain at least GET and POST
405 // Value could be "GET, POST" or "GET,POST" or include other methods
406 let methods: Vec<&str> = value.split(',').map(|s| s.trim()).collect();
407
408 if !methods.contains(&"GET") || !methods.contains(&"POST") {
409 return Err(format!(
410 "Expected Access-Control-Allow-Methods to include GET and POST, got: '{}' on {}",
411 value, context
412 ));
413 }
414
415 Ok(())
416}
417
418/// Check Access-Control-Allow-Headers header
419fn check_cors_allow_headers(response: &reqwest::Response, context: &str) -> Result<(), String> {
420 let header = response
421 .headers()
422 .get("Access-Control-Allow-Headers")
423 .ok_or_else(|| format!("Missing Access-Control-Allow-Headers header on {}", context))?;
424
425 let value = header
426 .to_str()
427 .map_err(|e| format!("Invalid Access-Control-Allow-Headers header value: {}", e))?;
428
429 // The header should contain at least Content-Type (case-insensitive)
430 let headers_lower = value.to_lowercase();
431 if !headers_lower.contains("content-type") {
432 return Err(format!(
433 "Expected Access-Control-Allow-Headers to include Content-Type, got: '{}' on {}",
434 value, context
435 ));
436 }
437
438 Ok(())
439}
440
441/// Check OPTIONS preflight response
442fn check_options_response(response: &reqwest::Response, context: &str) -> Result<(), String> {
443 // 1. Verify 204 No Content status
444 if response.status().as_u16() != 204 {
445 return Err(format!(
446 "Expected 204 No Content for OPTIONS on {}, got: {} {}",
447 context,
448 response.status().as_u16(),
449 response.status().canonical_reason().unwrap_or("Unknown")
450 ));
451 }
452
453 // 2. Also verify CORS headers are present on OPTIONS response
454 check_cors_allow_origin(response, &format!("OPTIONS {}", context))?;
455 check_cors_allow_methods(response, &format!("OPTIONS {}", context))?;
456 check_cors_allow_headers(response, &format!("OPTIONS {}", context))?;
457
458 Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::AuditConfig;
465
466 #[tokio::test]
467 #[ignore] // Requires running relay
468 async fn test_grasp01_cors_against_relay() {
469 // Read relay URL from environment variable - must be supplied
470 let relay_url = std::env::var("RELAY_URL").expect(
471 "RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081",
472 );
473
474 // Extract domain from relay URL for HTTP requests
475 let relay_domain = relay_url
476 .replace("ws://", "")
477 .replace("wss://", "")
478 .trim_end_matches('/')
479 .to_string();
480
481 let config = AuditConfig::ci();
482 let client = AuditClient::new(&relay_url, config)
483 .await
484 .unwrap_or_else(|_| {
485 panic!(
486 "Failed to connect to relay at {}. Ensure relay is running and accessible. \
487 Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest",
488 relay_url
489 )
490 });
491
492 let results = CorsTests::run_all(&client, &relay_domain).await;
493 results.print_report();
494
495 // Assert all tests passed
496 assert!(
497 results.all_passed(),
498 "Some GRASP-01 CORS tests failed"
499 );
500 }
501
502 #[test]
503 fn test_module_exists() {
504 // Simple compilation test
505 assert!(true);
506 }
507} \ No newline at end of file
diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs
index 495672a..8b29f8c 100644
--- a/grasp-audit/src/specs/grasp01/mod.rs
+++ b/grasp-audit/src/specs/grasp01/mod.rs
@@ -1,11 +1,13 @@
1//! GRASP-01 specification tests 1//! GRASP-01 specification tests
2 2
3pub mod cors;
3pub mod event_acceptance_policy; 4pub mod event_acceptance_policy;
4pub mod git_clone; 5pub mod git_clone;
5pub mod nip01_smoke; 6pub mod nip01_smoke;
6pub mod nip11_document; 7pub mod nip11_document;
7pub mod repository_creation; 8pub mod repository_creation;
8 9
10pub use cors::CorsTests;
9pub use event_acceptance_policy::EventAcceptancePolicyTests; 11pub use event_acceptance_policy::EventAcceptancePolicyTests;
10pub use git_clone::GitCloneTests; 12pub use git_clone::GitCloneTests;
11pub use nip01_smoke::Nip01SmokeTests; 13pub use nip01_smoke::Nip01SmokeTests;