diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-26 03:53:31 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-26 03:53:31 +0000 |
| commit | 963b2971ec2f43b1c2f669a969c294fc1d291d3b (patch) | |
| tree | 9495d5bd350ebf5123a9c72b9da3367b17e5534f | |
| parent | 75f3da90edb66b81dbb6ed9806155f6bd7925fe1 (diff) | |
add cors support
| -rw-r--r-- | grasp-audit/src/specs/grasp01/cors.rs | 507 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/mod.rs | 2 | ||||
| -rw-r--r-- | src/http/mod.rs | 46 | ||||
| -rw-r--r-- | tests/cors.rs | 95 |
4 files changed, 645 insertions, 5 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 | |||
| 17 | use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; | ||
| 18 | use nostr_sdk::prelude::*; | ||
| 19 | use std::path::Path; | ||
| 20 | |||
| 21 | pub struct CorsTests; | ||
| 22 | |||
| 23 | impl 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 | ||
| 373 | fn 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 | ||
| 394 | fn 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 | ||
| 419 | fn 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 | ||
| 442 | fn 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)] | ||
| 462 | mod 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 | ||
| 3 | pub mod cors; | ||
| 3 | pub mod event_acceptance_policy; | 4 | pub mod event_acceptance_policy; |
| 4 | pub mod git_clone; | 5 | pub mod git_clone; |
| 5 | pub mod nip01_smoke; | 6 | pub mod nip01_smoke; |
| 6 | pub mod nip11_document; | 7 | pub mod nip11_document; |
| 7 | pub mod repository_creation; | 8 | pub mod repository_creation; |
| 8 | 9 | ||
| 10 | pub use cors::CorsTests; | ||
| 9 | pub use event_acceptance_policy::EventAcceptancePolicyTests; | 11 | pub use event_acceptance_policy::EventAcceptancePolicyTests; |
| 10 | pub use git_clone::GitCloneTests; | 12 | pub use git_clone::GitCloneTests; |
| 11 | pub use nip01_smoke::Nip01SmokeTests; | 13 | pub use nip01_smoke::Nip01SmokeTests; |
diff --git a/src/http/mod.rs b/src/http/mod.rs index c676bda..85b72f4 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs | |||
| @@ -24,6 +24,19 @@ use base64::Engine; | |||
| 24 | use crate::config::Config; | 24 | use crate::config::Config; |
| 25 | use crate::git; | 25 | use crate::git; |
| 26 | 26 | ||
| 27 | /// CORS headers required by GRASP-01 specification (lines 40-47) | ||
| 28 | const CORS_ALLOW_ORIGIN: &str = "*"; | ||
| 29 | const CORS_ALLOW_METHODS: &str = "GET, POST"; | ||
| 30 | const CORS_ALLOW_HEADERS: &str = "Content-Type"; | ||
| 31 | |||
| 32 | /// Add CORS headers to a response builder | ||
| 33 | fn add_cors_headers(builder: hyper::http::response::Builder) -> hyper::http::response::Builder { | ||
| 34 | builder | ||
| 35 | .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN) | ||
| 36 | .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS) | ||
| 37 | .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS) | ||
| 38 | } | ||
| 39 | |||
| 27 | /// HTTP Service that serves both WebSocket (relay) and HTML landing page | 40 | /// HTTP Service that serves both WebSocket (relay) and HTML landing page |
| 28 | struct HttpService { | 41 | struct HttpService { |
| 29 | relay: LocalRelay, | 42 | relay: LocalRelay, |
| @@ -47,12 +60,23 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 47 | type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; | 60 | type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; |
| 48 | 61 | ||
| 49 | fn call(&self, req: Request<Incoming>) -> Self::Future { | 62 | fn call(&self, req: Request<Incoming>) -> Self::Future { |
| 50 | let base = Response::builder().header("server", "ngit-grasp"); | 63 | let base = add_cors_headers(Response::builder().header("server", "ngit-grasp")); |
| 51 | let path = req.uri().path().to_string(); | 64 | let path = req.uri().path().to_string(); |
| 52 | let query = req.uri().query().map(|s| s.to_string()); | 65 | let query = req.uri().query().map(|s| s.to_string()); |
| 53 | let method = req.method().clone(); | 66 | let method = req.method().clone(); |
| 54 | let git_data_path = self.config.git_data_path.clone(); | 67 | let git_data_path = self.config.git_data_path.clone(); |
| 55 | 68 | ||
| 69 | // Handle OPTIONS preflight requests (CORS) | ||
| 70 | // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content | ||
| 71 | if method == Method::OPTIONS { | ||
| 72 | return Box::pin(async move { | ||
| 73 | Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) | ||
| 74 | .status(204) | ||
| 75 | .body(Full::new(Bytes::new())) | ||
| 76 | .unwrap()) | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 56 | // Check for Git HTTP requests first | 80 | // Check for Git HTTP requests first |
| 57 | if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) { | 81 | if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) { |
| 58 | let npub = npub.to_string(); | 82 | let npub = npub.to_string(); |
| @@ -104,11 +128,24 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 104 | }; | 128 | }; |
| 105 | 129 | ||
| 106 | match result { | 130 | match result { |
| 107 | Ok(response) => Ok(response), | 131 | Ok(response) => { |
| 132 | // Add CORS headers to successful Git responses | ||
| 133 | let (parts, body) = response.into_parts(); | ||
| 134 | Ok(add_cors_headers(Response::builder() | ||
| 135 | .status(parts.status)) | ||
| 136 | .header("content-type", parts.headers.get("content-type") | ||
| 137 | .and_then(|v| v.to_str().ok()) | ||
| 138 | .unwrap_or("application/octet-stream")) | ||
| 139 | .header("cache-control", parts.headers.get("cache-control") | ||
| 140 | .and_then(|v| v.to_str().ok()) | ||
| 141 | .unwrap_or("no-cache")) | ||
| 142 | .body(body) | ||
| 143 | .unwrap()) | ||
| 144 | } | ||
| 108 | Err(e) => { | 145 | Err(e) => { |
| 109 | tracing::error!("Git handler error: {}", e); | 146 | tracing::error!("Git handler error: {}", e); |
| 110 | let error_msg = format!("Git error: {}", e); | 147 | let error_msg = format!("Git error: {}", e); |
| 111 | Ok(Response::builder() | 148 | Ok(add_cors_headers(Response::builder()) |
| 112 | .status(e.status_code()) | 149 | .status(e.status_code()) |
| 113 | .body(Full::new(Bytes::from(error_msg))) | 150 | .body(Full::new(Bytes::from(error_msg))) |
| 114 | .unwrap()) | 151 | .unwrap()) |
| @@ -133,10 +170,9 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 133 | tracing::debug!("Serving NIP-11 relay information document to {}", self.remote); | 170 | tracing::debug!("Serving NIP-11 relay information document to {}", self.remote); |
| 134 | 171 | ||
| 135 | return Box::pin(async move { | 172 | return Box::pin(async move { |
| 136 | Ok(base | 173 | Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) |
| 137 | .status(200) | 174 | .status(200) |
| 138 | .header("content-type", "application/nostr+json") | 175 | .header("content-type", "application/nostr+json") |
| 139 | .header("access-control-allow-origin", "*") | ||
| 140 | .body(Full::new(Bytes::from(json))) | 176 | .body(Full::new(Bytes::from(json))) |
| 141 | .unwrap()) | 177 | .unwrap()) |
| 142 | }); | 178 | }); |
diff --git a/tests/cors.rs b/tests/cors.rs new file mode 100644 index 0000000..9cce817 --- /dev/null +++ b/tests/cors.rs | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | //! CORS Integration Tests | ||
| 2 | //! | ||
| 3 | //! Tests that verify CORS headers are correctly set on Git HTTP backend responses. | ||
| 4 | //! | ||
| 5 | //! # Test Strategy | ||
| 6 | //! | ||
| 7 | //! - Each test runs in complete isolation with its own fresh relay instance | ||
| 8 | //! - Uses macro to eliminate boilerplate while maintaining test isolation | ||
| 9 | //! - Calls individual test methods from grasp-audit for minimal duplication | ||
| 10 | //! - Automatic cleanup via TestRelay fixture (removes container and temp dirs) | ||
| 11 | //! | ||
| 12 | //! # Running Tests | ||
| 13 | //! | ||
| 14 | //! ```bash | ||
| 15 | //! # Run all CORS tests | ||
| 16 | //! cargo test --test cors | ||
| 17 | //! | ||
| 18 | //! # Run specific test | ||
| 19 | //! cargo test --test cors test_cors_allow_origin | ||
| 20 | //! | ||
| 21 | //! # With output | ||
| 22 | //! cargo test --test cors -- --nocapture | ||
| 23 | //! ``` | ||
| 24 | |||
| 25 | mod common; | ||
| 26 | |||
| 27 | use common::TestRelay; | ||
| 28 | use grasp_audit::specs::grasp01::CorsTests; | ||
| 29 | use grasp_audit::*; | ||
| 30 | |||
| 31 | /// Macro to generate isolated CORS integration tests with relay domain | ||
| 32 | /// | ||
| 33 | /// Each test runs with its own fresh relay instance to ensure complete isolation. | ||
| 34 | macro_rules! isolated_cors_test { | ||
| 35 | ($test_name:ident) => { | ||
| 36 | #[tokio::test] | ||
| 37 | async fn $test_name() { | ||
| 38 | let relay = TestRelay::start().await; | ||
| 39 | let config = AuditConfig::ci(); | ||
| 40 | let client = AuditClient::new(relay.url(), config) | ||
| 41 | .await | ||
| 42 | .expect("Failed to create audit client"); | ||
| 43 | |||
| 44 | let result = CorsTests::$test_name(&client, &relay.domain()).await; | ||
| 45 | |||
| 46 | relay.stop().await; | ||
| 47 | |||
| 48 | assert!( | ||
| 49 | result.passed, | ||
| 50 | "{} failed: {}", | ||
| 51 | stringify!($test_name), | ||
| 52 | result.error.as_deref().unwrap_or("unknown error") | ||
| 53 | ); | ||
| 54 | } | ||
| 55 | }; | ||
| 56 | } | ||
| 57 | |||
| 58 | /// Macro for CORS tests that need git_data_dir (the full integration test) | ||
| 59 | macro_rules! isolated_cors_test_with_repo { | ||
| 60 | ($test_name:ident) => { | ||
| 61 | #[tokio::test] | ||
| 62 | async fn $test_name() { | ||
| 63 | let relay = TestRelay::start().await; | ||
| 64 | let config = AuditConfig::ci(); | ||
| 65 | let client = AuditClient::new(relay.url(), config) | ||
| 66 | .await | ||
| 67 | .expect("Failed to create audit client"); | ||
| 68 | |||
| 69 | let result = CorsTests::$test_name( | ||
| 70 | &client, | ||
| 71 | relay.git_data_dir(), | ||
| 72 | &relay.domain(), | ||
| 73 | ) | ||
| 74 | .await; | ||
| 75 | |||
| 76 | relay.stop().await; | ||
| 77 | |||
| 78 | assert!( | ||
| 79 | result.passed, | ||
| 80 | "{} failed: {}", | ||
| 81 | stringify!($test_name), | ||
| 82 | result.error.as_deref().unwrap_or("unknown error") | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | // Generate isolated tests for all CORS tests | ||
| 89 | isolated_cors_test!(test_cors_allow_origin); | ||
| 90 | isolated_cors_test!(test_cors_allow_methods); | ||
| 91 | isolated_cors_test!(test_cors_allow_headers); | ||
| 92 | isolated_cors_test!(test_cors_options_preflight); | ||
| 93 | |||
| 94 | // Integration test that creates a real repository and tests CORS on it | ||
| 95 | isolated_cors_test_with_repo!(test_cors_on_real_repo); \ No newline at end of file | ||