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 + src/http/mod.rs | 46 ++- tests/cors.rs | 95 +++++++ 4 files changed, 645 insertions(+), 5 deletions(-) create mode 100644 grasp-audit/src/specs/grasp01/cors.rs create mode 100644 tests/cors.rs 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; 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; use crate::config::Config; use crate::git; +/// CORS headers required by GRASP-01 specification (lines 40-47) +const CORS_ALLOW_ORIGIN: &str = "*"; +const CORS_ALLOW_METHODS: &str = "GET, POST"; +const CORS_ALLOW_HEADERS: &str = "Content-Type"; + +/// Add CORS headers to a response builder +fn add_cors_headers(builder: hyper::http::response::Builder) -> hyper::http::response::Builder { + builder + .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN) + .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS) + .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS) +} + /// HTTP Service that serves both WebSocket (relay) and HTML landing page struct HttpService { relay: LocalRelay, @@ -47,12 +60,23 @@ impl Service> for HttpService { type Future = Pin> + Send>>; fn call(&self, req: Request) -> Self::Future { - let base = Response::builder().header("server", "ngit-grasp"); + let base = add_cors_headers(Response::builder().header("server", "ngit-grasp")); let path = req.uri().path().to_string(); let query = req.uri().query().map(|s| s.to_string()); let method = req.method().clone(); let git_data_path = self.config.git_data_path.clone(); + // Handle OPTIONS preflight requests (CORS) + // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content + if method == Method::OPTIONS { + return Box::pin(async move { + Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(204) + .body(Full::new(Bytes::new())) + .unwrap()) + }); + } + // Check for Git HTTP requests first if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) { let npub = npub.to_string(); @@ -104,11 +128,24 @@ impl Service> for HttpService { }; match result { - Ok(response) => Ok(response), + Ok(response) => { + // Add CORS headers to successful Git responses + let (parts, body) = response.into_parts(); + Ok(add_cors_headers(Response::builder() + .status(parts.status)) + .header("content-type", parts.headers.get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream")) + .header("cache-control", parts.headers.get("cache-control") + .and_then(|v| v.to_str().ok()) + .unwrap_or("no-cache")) + .body(body) + .unwrap()) + } Err(e) => { tracing::error!("Git handler error: {}", e); let error_msg = format!("Git error: {}", e); - Ok(Response::builder() + Ok(add_cors_headers(Response::builder()) .status(e.status_code()) .body(Full::new(Bytes::from(error_msg))) .unwrap()) @@ -133,10 +170,9 @@ impl Service> for HttpService { tracing::debug!("Serving NIP-11 relay information document to {}", self.remote); return Box::pin(async move { - Ok(base + Ok(add_cors_headers(Response::builder().header("server", "ngit-grasp")) .status(200) .header("content-type", "application/nostr+json") - .header("access-control-allow-origin", "*") .body(Full::new(Bytes::from(json))) .unwrap()) }); 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 @@ +//! CORS Integration Tests +//! +//! Tests that verify CORS headers are correctly set on Git HTTP backend responses. +//! +//! # Test Strategy +//! +//! - Each test runs in complete isolation with its own fresh relay instance +//! - Uses macro to eliminate boilerplate while maintaining test isolation +//! - Calls individual test methods from grasp-audit for minimal duplication +//! - Automatic cleanup via TestRelay fixture (removes container and temp dirs) +//! +//! # Running Tests +//! +//! ```bash +//! # Run all CORS tests +//! cargo test --test cors +//! +//! # Run specific test +//! cargo test --test cors test_cors_allow_origin +//! +//! # With output +//! cargo test --test cors -- --nocapture +//! ``` + +mod common; + +use common::TestRelay; +use grasp_audit::specs::grasp01::CorsTests; +use grasp_audit::*; + +/// Macro to generate isolated CORS integration tests with relay domain +/// +/// Each test runs with its own fresh relay instance to ensure complete isolation. +macro_rules! isolated_cors_test { + ($test_name:ident) => { + #[tokio::test] + async fn $test_name() { + let relay = TestRelay::start().await; + let config = AuditConfig::ci(); + let client = AuditClient::new(relay.url(), config) + .await + .expect("Failed to create audit client"); + + let result = CorsTests::$test_name(&client, &relay.domain()).await; + + relay.stop().await; + + assert!( + result.passed, + "{} failed: {}", + stringify!($test_name), + result.error.as_deref().unwrap_or("unknown error") + ); + } + }; +} + +/// Macro for CORS tests that need git_data_dir (the full integration test) +macro_rules! isolated_cors_test_with_repo { + ($test_name:ident) => { + #[tokio::test] + async fn $test_name() { + let relay = TestRelay::start().await; + let config = AuditConfig::ci(); + let client = AuditClient::new(relay.url(), config) + .await + .expect("Failed to create audit client"); + + let result = CorsTests::$test_name( + &client, + relay.git_data_dir(), + &relay.domain(), + ) + .await; + + relay.stop().await; + + assert!( + result.passed, + "{} failed: {}", + stringify!($test_name), + result.error.as_deref().unwrap_or("unknown error") + ); + } + }; +} + +// Generate isolated tests for all CORS tests +isolated_cors_test!(test_cors_allow_origin); +isolated_cors_test!(test_cors_allow_methods); +isolated_cors_test!(test_cors_allow_headers); +isolated_cors_test!(test_cors_options_preflight); + +// Integration test that creates a real repository and tests CORS on it +isolated_cors_test_with_repo!(test_cors_on_real_repo); \ No newline at end of file -- cgit v1.2.3