From 963b2971ec2f43b1c2f669a969c294fc1d291d3b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 26 Nov 2025 03:53:31 +0000 Subject: add cors support --- grasp-audit/src/specs/grasp01/cors.rs | 507 ++++++++++++++++++++++++++++++++++ grasp-audit/src/specs/grasp01/mod.rs | 2 + 2 files changed, 509 insertions(+) create mode 100644 grasp-audit/src/specs/grasp01/cors.rs (limited to 'grasp-audit/src') 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 @@ +//! GRASP-01 CORS Tests +//! +//! Tests for GRASP-01 CORS requirements (lines 40-47 of ../grasp/01.md) +//! +//! These tests validate that a GRASP-01 compliant relay implements CORS correctly: +//! - Sets `Access-Control-Allow-Origin: *` on ALL responses +//! - Sets `Access-Control-Allow-Methods: GET, POST` on ALL responses +//! - Sets `Access-Control-Allow-Headers: Content-Type` on ALL responses +//! - Responds to OPTIONS requests with 204 No Content +//! +//! ## Running Tests +//! +//! ```bash +//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test +//! ``` + +use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; +use nostr_sdk::prelude::*; +use std::path::Path; + +pub struct CorsTests; + +impl CorsTests { + /// Run all CORS tests + pub async fn run_all(client: &AuditClient, relay_domain: &str) -> AuditResult { + let mut results = AuditResult::new("GRASP-01 CORS Tests"); + + // CORS tests against Git HTTP endpoints + results.add(Self::test_cors_allow_origin(client, relay_domain).await); + results.add(Self::test_cors_allow_methods(client, relay_domain).await); + results.add(Self::test_cors_allow_headers(client, relay_domain).await); + results.add(Self::test_cors_options_preflight(client, relay_domain).await); + + results + } + + // ========================================================================= + // CORS Tests + // ========================================================================= + + /// Test: Access-Control-Allow-Origin header on all responses + /// + /// Spec: Line 44 of ../grasp/01.md + /// Requirement: Set `Access-Control-Allow-Origin: *` on ALL responses + pub async fn test_cors_allow_origin( + _client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + TestResult::new( + "cors_allow_origin", + "GRASP-01:git-http:cors:44", + "Access-Control-Allow-Origin: * on all responses", + ) + .run(|| { + let relay_domain = relay_domain.to_string(); + async move { + // Test multiple endpoints to verify "ALL responses" requirement + let http_client = reqwest::Client::new(); + + // 1. Test root endpoint + let root_url = format!("http://{}/", relay_domain); + let response = http_client + .get(&root_url) + .send() + .await + .map_err(|e| format!("Failed to GET root: {}", e))?; + + check_cors_allow_origin(&response, "root endpoint")?; + + // 2. Test a non-existent repo path (still should have CORS headers) + let repo_url = format!( + "http://{}/npub1test/nonexistent.git/info/refs?service=git-upload-pack", + relay_domain + ); + let response = http_client + .get(&repo_url) + .send() + .await + .map_err(|e| format!("Failed to GET repo endpoint: {}", e))?; + + // Even 404 responses must have CORS headers + check_cors_allow_origin(&response, "git-upload-pack endpoint (even if 404)")?; + + Ok(()) + } + }) + .await + } + + /// Test: Access-Control-Allow-Methods header on all responses + /// + /// Spec: Line 45 of ../grasp/01.md + /// Requirement: Set `Access-Control-Allow-Methods: GET, POST` on ALL responses + pub async fn test_cors_allow_methods( + _client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + TestResult::new( + "cors_allow_methods", + "GRASP-01:git-http:cors:45", + "Access-Control-Allow-Methods: GET, POST on all responses", + ) + .run(|| { + let relay_domain = relay_domain.to_string(); + async move { + let http_client = reqwest::Client::new(); + + // Test root endpoint + let root_url = format!("http://{}/", relay_domain); + let response = http_client + .get(&root_url) + .send() + .await + .map_err(|e| format!("Failed to GET root: {}", e))?; + + check_cors_allow_methods(&response, "root endpoint")?; + + // Test a repo path + let repo_url = format!( + "http://{}/npub1test/nonexistent.git/info/refs?service=git-upload-pack", + relay_domain + ); + let response = http_client + .get(&repo_url) + .send() + .await + .map_err(|e| format!("Failed to GET repo endpoint: {}", e))?; + + check_cors_allow_methods(&response, "git-upload-pack endpoint")?; + + Ok(()) + } + }) + .await + } + + /// Test: Access-Control-Allow-Headers header on all responses + /// + /// Spec: Line 46 of ../grasp/01.md + /// Requirement: Set `Access-Control-Allow-Headers: Content-Type` on ALL responses + pub async fn test_cors_allow_headers( + _client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + TestResult::new( + "cors_allow_headers", + "GRASP-01:git-http:cors:46", + "Access-Control-Allow-Headers: Content-Type on all responses", + ) + .run(|| { + let relay_domain = relay_domain.to_string(); + async move { + let http_client = reqwest::Client::new(); + + // Test root endpoint + let root_url = format!("http://{}/", relay_domain); + let response = http_client + .get(&root_url) + .send() + .await + .map_err(|e| format!("Failed to GET root: {}", e))?; + + check_cors_allow_headers(&response, "root endpoint")?; + + // Test a repo path + let repo_url = format!( + "http://{}/npub1test/nonexistent.git/info/refs?service=git-upload-pack", + relay_domain + ); + let response = http_client + .get(&repo_url) + .send() + .await + .map_err(|e| format!("Failed to GET repo endpoint: {}", e))?; + + check_cors_allow_headers(&response, "git-upload-pack endpoint")?; + + Ok(()) + } + }) + .await + } + + /// Test: OPTIONS preflight requests return 204 No Content + /// + /// Spec: Line 47 of ../grasp/01.md + /// Requirement: Respond to OPTIONS requests with 204 No Content + pub async fn test_cors_options_preflight( + _client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + TestResult::new( + "cors_options_preflight", + "GRASP-01:git-http:cors:47", + "OPTIONS requests return 204 No Content", + ) + .run(|| { + let relay_domain = relay_domain.to_string(); + async move { + let http_client = reqwest::Client::new(); + + // 1. Test OPTIONS on root endpoint + let root_url = format!("http://{}/", relay_domain); + let response = http_client + .request(reqwest::Method::OPTIONS, &root_url) + .header("Origin", "https://example.com") + .header("Access-Control-Request-Method", "POST") + .send() + .await + .map_err(|e| format!("Failed to OPTIONS root: {}", e))?; + + check_options_response(&response, "root endpoint")?; + + // 2. Test OPTIONS on git-upload-pack endpoint + let repo_url = format!( + "http://{}/npub1test/test.git/git-upload-pack", + relay_domain + ); + let response = http_client + .request(reqwest::Method::OPTIONS, &repo_url) + .header("Origin", "https://example.com") + .header("Access-Control-Request-Method", "POST") + .send() + .await + .map_err(|e| format!("Failed to OPTIONS git-upload-pack: {}", e))?; + + check_options_response(&response, "git-upload-pack endpoint")?; + + // 3. Test OPTIONS on info/refs endpoint + let refs_url = format!( + "http://{}/npub1test/test.git/info/refs", + relay_domain + ); + let response = http_client + .request(reqwest::Method::OPTIONS, &refs_url) + .header("Origin", "https://example.com") + .header("Access-Control-Request-Method", "GET") + .send() + .await + .map_err(|e| format!("Failed to OPTIONS info/refs: {}", e))?; + + check_options_response(&response, "info/refs endpoint")?; + + Ok(()) + } + }) + .await + } + + // ========================================================================= + // Integration test methods for use from external test files + // These match the pattern used by GitCloneTests + // ========================================================================= + + /// Integration test: CORS Allow-Origin header with repository creation + /// + /// For integration tests that want to test against real repositories + pub async fn test_cors_on_real_repo( + client: &AuditClient, + _git_data_dir: &Path, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_cors_on_real_repo"; + let ctx = TestContext::new(client); + + // Create repository announcement to get a real repo path + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail(&format!("Failed to create repo fixture: {}", e)) + } + }; + + // Wait for repository creation + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo identifier and npub + let repo_id = match repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail("Repository announcement missing d tag") + } + }; + + let npub = match repo.pubkey.to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail(&format!("Failed to convert pubkey to npub: {}", e)) + } + }; + + // Test CORS on real repo endpoint + let http_client = reqwest::Client::new(); + let info_refs_url = format!( + "http://{}/{}/{}.git/info/refs?service=git-upload-pack", + relay_domain, npub, repo_id + ); + + let response = match http_client.get(&info_refs_url).send().await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail(&format!("Failed to GET info/refs: {}", e)) + } + }; + + // Check all CORS headers + if let Err(e) = check_cors_allow_origin(&response, "info/refs") { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail(&e); + } + + if let Err(e) = check_cors_allow_methods(&response, "info/refs") { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail(&e); + } + + if let Err(e) = check_cors_allow_headers(&response, "info/refs") { + return TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .fail(&e); + } + + TestResult::new( + test_name, + "GRASP-01", + "CORS headers on real repository endpoint", + ) + .pass() + } +} + +// ========================================================================= +// Helper functions +// ========================================================================= + +/// Check Access-Control-Allow-Origin header +fn check_cors_allow_origin(response: &reqwest::Response, context: &str) -> Result<(), String> { + let header = response + .headers() + .get("Access-Control-Allow-Origin") + .ok_or_else(|| format!("Missing Access-Control-Allow-Origin header on {}", context))?; + + let value = header + .to_str() + .map_err(|e| format!("Invalid Access-Control-Allow-Origin header value: {}", e))?; + + if value != "*" { + return Err(format!( + "Expected Access-Control-Allow-Origin: *, got: '{}' on {}", + value, context + )); + } + + Ok(()) +} + +/// Check Access-Control-Allow-Methods header +fn check_cors_allow_methods(response: &reqwest::Response, context: &str) -> Result<(), String> { + let header = response + .headers() + .get("Access-Control-Allow-Methods") + .ok_or_else(|| format!("Missing Access-Control-Allow-Methods header on {}", context))?; + + let value = header + .to_str() + .map_err(|e| format!("Invalid Access-Control-Allow-Methods header value: {}", e))?; + + // The header should contain at least GET and POST + // Value could be "GET, POST" or "GET,POST" or include other methods + let methods: Vec<&str> = value.split(',').map(|s| s.trim()).collect(); + + if !methods.contains(&"GET") || !methods.contains(&"POST") { + return Err(format!( + "Expected Access-Control-Allow-Methods to include GET and POST, got: '{}' on {}", + value, context + )); + } + + Ok(()) +} + +/// Check Access-Control-Allow-Headers header +fn check_cors_allow_headers(response: &reqwest::Response, context: &str) -> Result<(), String> { + let header = response + .headers() + .get("Access-Control-Allow-Headers") + .ok_or_else(|| format!("Missing Access-Control-Allow-Headers header on {}", context))?; + + let value = header + .to_str() + .map_err(|e| format!("Invalid Access-Control-Allow-Headers header value: {}", e))?; + + // The header should contain at least Content-Type (case-insensitive) + let headers_lower = value.to_lowercase(); + if !headers_lower.contains("content-type") { + return Err(format!( + "Expected Access-Control-Allow-Headers to include Content-Type, got: '{}' on {}", + value, context + )); + } + + Ok(()) +} + +/// Check OPTIONS preflight response +fn check_options_response(response: &reqwest::Response, context: &str) -> Result<(), String> { + // 1. Verify 204 No Content status + if response.status().as_u16() != 204 { + return Err(format!( + "Expected 204 No Content for OPTIONS on {}, got: {} {}", + context, + response.status().as_u16(), + response.status().canonical_reason().unwrap_or("Unknown") + )); + } + + // 2. Also verify CORS headers are present on OPTIONS response + check_cors_allow_origin(response, &format!("OPTIONS {}", context))?; + check_cors_allow_methods(response, &format!("OPTIONS {}", context))?; + check_cors_allow_headers(response, &format!("OPTIONS {}", context))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AuditConfig; + + #[tokio::test] + #[ignore] // Requires running relay + async fn test_grasp01_cors_against_relay() { + // Read relay URL from environment variable - must be supplied + let relay_url = std::env::var("RELAY_URL").expect( + "RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081", + ); + + // Extract domain from relay URL for HTTP requests + let relay_domain = relay_url + .replace("ws://", "") + .replace("wss://", "") + .trim_end_matches('/') + .to_string(); + + let config = AuditConfig::ci(); + let client = AuditClient::new(&relay_url, config) + .await + .unwrap_or_else(|_| { + panic!( + "Failed to connect to relay at {}. Ensure relay is running and accessible. \ + Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest", + relay_url + ) + }); + + let results = CorsTests::run_all(&client, &relay_domain).await; + results.print_report(); + + // Assert all tests passed + assert!( + results.all_passed(), + "Some GRASP-01 CORS tests failed" + ); + } + + #[test] + fn test_module_exists() { + // Simple compilation test + assert!(true); + } +} \ 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 @@ //! GRASP-01 specification tests +pub mod cors; pub mod event_acceptance_policy; pub mod git_clone; pub mod nip01_smoke; pub mod nip11_document; pub mod repository_creation; +pub use cors::CorsTests; pub use event_acceptance_policy::EventAcceptancePolicyTests; pub use git_clone::GitCloneTests; pub use nip01_smoke::Nip01SmokeTests; -- cgit v1.2.3