From d9bc5ed7fddef3a26de8e69a7124e1dbe5b8602f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 4 Dec 2025 12:34:20 +0000 Subject: docs: update based on current implementation --- docs/reference/test-strategy.md | 1339 ++++++--------------------------------- 1 file changed, 207 insertions(+), 1132 deletions(-) (limited to 'docs/reference') diff --git a/docs/reference/test-strategy.md b/docs/reference/test-strategy.md index cc1d5b0..7a31bdf 100644 --- a/docs/reference/test-strategy.md +++ b/docs/reference/test-strategy.md @@ -2,15 +2,15 @@ ## Overview -This document outlines the comprehensive testing strategy for ngit-grasp, including a **reusable GRASP compliance testing tool** that can validate any GRASP implementation against the protocol specification. +This document describes the testing strategy for ngit-grasp, including the **grasp-audit** reusable compliance testing tool and the **integration tests** in the main repository. ## Testing Philosophy -1. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly +1. **Specification-Driven**: Tests mirror GRASP-01 protocol structure exactly 2. **Compliance-First**: Every requirement in the spec has a corresponding test -3. **Reusable**: Compliance tests can validate any GRASP implementation -4. **Clear Failures**: Test failures cite exact spec lines/sections -5. **Comprehensive**: Unit, integration, and compliance testing +3. **Reusable**: The grasp-audit tool can validate any GRASP implementation +4. **Isolated**: Each test runs with its own relay instance via [`TestRelay`](tests/common/relay.rs:14) +5. **Clear Failures**: Test failures cite exact spec requirements ## Test Pyramid @@ -20,1219 +20,294 @@ This document outlines the comprehensive testing strategy for ngit-grasp, includ ╱ E2E╲ ~ 10% End-to-end with real Git ╱──────╲ ╱ ╲ - ╱Compliance╲ ~ 20% GRASP spec validation - ╱────────────╲ + ╱Compliance╲ ~ 30% GRASP-01 spec validation + ╱────────────╲ (grasp-audit) ╱ ╲ ╱ Integration ╲ ~ 30% Component interaction - ╱──────────────────╲ + ╱──────────────────╲ (tests/) ╱ ╲ - ╱ Unit Tests ╲ ~ 40% Individual functions - ╱────────────────────────╲ + ╱ Unit Tests ╲ ~ 30% Individual functions + ╱────────────────────────╲ (src/**/tests) ``` -## GRASP Compliance Testing Tool +## Project Structure -### Design Goals - -1. **Reusable**: Can test ngit-grasp or any other GRASP implementation -2. **Spec-Mirrored**: Test structure matches GRASP protocol documents -3. **Clear Reporting**: Failures cite exact spec requirements -4. **Automated**: Can run in CI/CD -5. **Extensible**: Easy to add new GRASP versions (GRASP-02, GRASP-05) - -### Project Structure +### Actual Test Layout ``` -grasp-compliance-tests/ -├── Cargo.toml # Standalone crate -├── README.md # Usage instructions -├── src/ -│ ├── lib.rs # Public API -│ ├── client.rs # Test client utilities -│ ├── assertions.rs # Spec-based assertions -│ └── specs/ -│ ├── mod.rs # Spec registry -│ ├── grasp_01.rs # GRASP-01 tests -│ ├── grasp_02.rs # GRASP-02 tests -│ └── grasp_05.rs # GRASP-05 tests -├── fixtures/ -│ ├── repos/ # Test repositories -│ ├── events/ # Nostr event fixtures -│ └── keys/ # Test keypairs -└── examples/ - └── test_implementation.rs # Example usage +ngit-grasp/ +├── tests/ # Integration tests for ngit-grasp +│ ├── common/ +│ │ ├── mod.rs # Test utilities module +│ │ └── relay.rs # TestRelay fixture +│ ├── nip01_compliance.rs # NIP-01 relay compliance +│ ├── nip11_document.rs # NIP-11 document tests +│ ├── nip34_announcements.rs # Repository announcement tests +│ ├── repository_creation.rs # Git repo creation tests +│ ├── push_authorization.rs # Push validation tests +│ ├── cors.rs # CORS header tests +│ └── git_clone.rs # Git clone tests +│ +└── grasp-audit/ # Reusable GRASP compliance tool + ├── Cargo.toml + ├── flake.nix + └── src/ + ├── lib.rs # Public API + ├── client.rs # AuditClient + ├── audit.rs # AuditConfig, cleanup tags + ├── fixtures.rs # Test fixtures + └── specs/ + └── grasp01/ # GRASP-01 specification tests + ├── mod.rs # Module exports + ├── nip01_smoke.rs # NIP-01 smoke tests + ├── nip11_document.rs + ├── event_acceptance_policy.rs + ├── cors.rs + ├── git_clone.rs + ├── push_authorization.rs + ├── repository_creation.rs + └── spec_requirements.rs # Requirement definitions ``` -### Spec-Mirrored Test Structure +## Integration Tests (tests/) + +### TestRelay Fixture -Each GRASP spec document maps to a test module with identical structure: +The [`TestRelay`](tests/common/relay.rs:14) fixture provides automatic relay lifecycle management: ```rust -// src/specs/grasp_01.rs - -use crate::{TestContext, SpecRequirement, ComplianceResult}; - -/// GRASP-01 - Core Service Requirements -/// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md -pub struct Grasp01Spec; - -impl Grasp01Spec { - /// Run all GRASP-01 compliance tests - pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult { - let mut results = ComplianceResult::new("GRASP-01"); - - // Section: Nostr Relay - results.add(Self::test_nostr_relay_nip01_compliance(ctx).await); - results.add(Self::test_accepts_repository_announcements(ctx).await); - results.add(Self::test_accepts_repository_state_announcements(ctx).await); - results.add(Self::test_rejects_unlisted_announcements(ctx).await); - results.add(Self::test_accepts_related_events(ctx).await); - results.add(Self::test_serves_nip11_document(ctx).await); - results.add(Self::test_nip11_has_supported_grasps(ctx).await); - results.add(Self::test_nip11_has_repo_acceptance_criteria(ctx).await); - results.add(Self::test_nip11_has_curation_policy(ctx).await); - - // Section: Git Smart HTTP Service - results.add(Self::test_serves_git_at_correct_path(ctx).await); - results.add(Self::test_accepts_matching_pushes(ctx).await); - results.add(Self::test_rejects_mismatched_pushes(ctx).await); - results.add(Self::test_respects_recursive_maintainers(ctx).await); - results.add(Self::test_sets_head_from_state(ctx).await); - results.add(Self::test_accepts_nostr_refs(ctx).await); - results.add(Self::test_rejects_pr_branches(ctx).await); - results.add(Self::test_deletes_orphaned_nostr_refs(ctx).await); - results.add(Self::test_allows_reachable_sha1_in_want(ctx).await); - results.add(Self::test_allows_tip_sha1_in_want(ctx).await); - results.add(Self::test_serves_webpage(ctx).await); - - // Section: CORS Support - results.add(Self::test_cors_allow_origin(ctx).await); - results.add(Self::test_cors_allow_methods(ctx).await); - results.add(Self::test_cors_allow_headers(ctx).await); - results.add(Self::test_cors_options_request(ctx).await); - - results - } - - // ================================================================ - // NOSTR RELAY TESTS - // ================================================================ - - /// MUST serve a NIP-01 compliant nostr relay at `/` - /// - /// Spec: GRASP-01, Line 9-10 - /// > MUST serve a [NIP-01](https://nips.nostr.com/1) compliant nostr - /// > relay at `/` that accepts [git repository announcements]... - async fn test_nostr_relay_nip01_compliance(ctx: &TestContext) -> TestResult { - TestResult::new( - "nostr_relay_nip01_compliance", - "GRASP-01:9-10", - "MUST serve a NIP-01 compliant nostr relay at `/`", - ) - .run(async { - // Test WebSocket upgrade at / - let ws = ctx.connect_websocket("/").await?; - - // Test NIP-01 REQ/EVENT/CLOSE/NOTICE messages - ws.send_req("test-sub", vec![]).await?; - let response = ws.recv().await?; - assert_nip01_eose(response)?; - - Ok(()) - }) - .await - } - - /// MUST reject announcements that do not list the service in both - /// `clone` and `relays` tags unless implementing `GRASP-05` - /// - /// Spec: GRASP-01, Line 12-13 - /// > MUST reject [git repository announcements] that do not list the - /// > service in both `clone` and `relays` tags unless implementing `GRASP-05`. - async fn test_rejects_unlisted_announcements(ctx: &TestContext) -> TestResult { - TestResult::new( - "rejects_unlisted_announcements", - "GRASP-01:12-13", - "MUST reject announcements not listing service in clone and relays", - ) - .run(async { - let event = ctx.create_announcement() - .without_clone_tag(ctx.domain()) - .build() - .await?; - - let result = ctx.send_event(event).await?; - - assert_eq!( - result.ok, false, - "Expected rejection of announcement without clone tag" - ); - assert!( - result.message.contains("clone") || result.message.contains("relays"), - "Expected rejection message to mention clone/relays requirement" - ); - - Ok(()) - }) - .await - } - - /// MUST accept other events that tag, or are tagged by, accepted announcements - /// - /// Spec: GRASP-01, Line 17-20 - /// > MUST accept other events that tag, or are tagged by, either: - /// > 1. accepted [git repository announcements]; or - /// > 2. accepted [issues] or [patches] - async fn test_accepts_related_events(ctx: &TestContext) -> TestResult { - TestResult::new( - "accepts_related_events", - "GRASP-01:17-20", - "MUST accept events that tag or are tagged by accepted announcements", - ) - .run(async { - // First, create and accept an announcement - let announcement = ctx.create_announcement() - .with_clone_tag(ctx.domain()) - .with_relay_tag(ctx.domain()) - .build() - .await?; - - ctx.send_event(announcement.clone()).await?; - - // Now send an issue that tags the announcement - let issue = ctx.create_issue() - .tag_announcement(&announcement) - .build() - .await?; - - let result = ctx.send_event(issue).await?; - - assert_eq!( - result.ok, true, - "Expected acceptance of issue tagging accepted announcement" - ); - - Ok(()) - }) - .await - } - - /// MUST serve a NIP-11 document with required fields - /// - /// Spec: GRASP-01, Line 24-27 - /// > MUST serve a [NIP-11] document: - /// > 1. MUST list each supported GRASP under `supported_grasps` - /// > 2. MUST list repository acceptance criteria under `repo_acceptance_criteria` - /// > 3. MUST list curation policy under `curation` if events are curated - async fn test_serves_nip11_document(ctx: &TestContext) -> TestResult { - TestResult::new( - "serves_nip11_document", - "GRASP-01:24-27", - "MUST serve a NIP-11 document", - ) - .run(async { - let nip11 = ctx.fetch_nip11().await?; - - assert!( - nip11.contains_key("supported_nips"), - "NIP-11 document must have supported_nips" - ); - - Ok(()) - }) - .await - } - - /// NIP-11 MUST list supported GRASPs - /// - /// Spec: GRASP-01, Line 25 - /// > 1. MUST list each supported GRASP under `supported_grasps` - /// > in format `GRASP-XX` eg `GRASP-01` as a string array - async fn test_nip11_has_supported_grasps(ctx: &TestContext) -> TestResult { - TestResult::new( - "nip11_has_supported_grasps", - "GRASP-01:25", - "NIP-11 MUST list supported_grasps as string array", - ) - .run(async { - let nip11 = ctx.fetch_nip11().await?; - - let grasps = nip11.get("supported_grasps") - .ok_or("NIP-11 missing supported_grasps field")? - .as_array() - .ok_or("supported_grasps must be an array")?; - - assert!( - grasps.iter().any(|g| g.as_str() == Some("GRASP-01")), - "supported_grasps must include 'GRASP-01'" - ); - - // Validate format: GRASP-XX - for grasp in grasps { - let s = grasp.as_str().ok_or("GRASP must be a string")?; - assert!( - s.starts_with("GRASP-") && s.len() >= 8, - "GRASP format must be 'GRASP-XX', got: {}", s - ); - } - - Ok(()) - }) - .await - } - - // ================================================================ - // GIT SMART HTTP SERVICE TESTS - // ================================================================ - - /// MUST serve a git repository via git smart http at //.git - /// - /// Spec: GRASP-01, Line 31-32 - /// > MUST serve a git repository via an unauthenticated [git smart http service] - /// > at `//.git` for each accepted announcement - async fn test_serves_git_at_correct_path(ctx: &TestContext) -> TestResult { - TestResult::new( - "serves_git_at_correct_path", - "GRASP-01:31-32", - "MUST serve git at //.git", - ) - .run(async { - // Create and send announcement - let announcement = ctx.create_announcement() - .with_identifier("test-repo") - .with_clone_tag(ctx.domain()) - .with_relay_tag(ctx.domain()) - .build() - .await?; - - let npub = announcement.author_npub(); - ctx.send_event(announcement).await?; - - // Wait for repo creation - tokio::time::sleep(Duration::from_secs(2)).await; - - // Test git info/refs endpoint - let path = format!("/{}/test-repo.git/info/refs?service=git-upload-pack", npub); - let response = ctx.http_get(&path).await?; - - assert_eq!( - response.status(), 200, - "Git info/refs must return 200 OK" - ); - - assert_eq!( - response.headers().get("content-type").unwrap(), - "application/x-git-upload-pack-advertisement", - "Git info/refs must have correct content-type" - ); - - Ok(()) - }) - .await - } - - /// MUST accept pushes that match the latest state announcement - /// - /// Spec: GRASP-01, Line 34-35 - /// > MUST accept pushes via this service that match the latest - /// > [repo state announcement] on the relay, respecting the recursive maintainer set. - async fn test_accepts_matching_pushes(ctx: &TestContext) -> TestResult { - TestResult::new( - "accepts_matching_pushes", - "GRASP-01:34-35", - "MUST accept pushes matching latest state announcement", - ) - .run(async { - // Setup: Create repo with announcement and state - let (announcement, state) = ctx.create_repo_with_state() - .branch("main", "a1b2c3d4...") - .build() - .await?; - - // Push matching state - let result = ctx.git_push(&announcement, "main", "a1b2c3d4...").await?; - - assert!( - result.success, - "Push matching state must succeed, got: {}", result.stderr - ); - - Ok(()) - }) - .await - } - - /// MUST reject pushes that don't match the state announcement - /// - /// Spec: GRASP-01, Line 34-35 (inverse requirement) - /// Implied by "MUST accept pushes... that match" - async fn test_rejects_mismatched_pushes(ctx: &TestContext) -> TestResult { - TestResult::new( - "rejects_mismatched_pushes", - "GRASP-01:34-35", - "MUST reject pushes not matching state announcement", - ) - .run(async { - // Setup: Create repo with state pointing to commit A - let (announcement, state) = ctx.create_repo_with_state() - .branch("main", "aaaa1111...") - .build() - .await?; - - // Try to push different commit B - let result = ctx.git_push(&announcement, "main", "bbbb2222...").await; - - assert!( - result.is_err() || !result.unwrap().success, - "Push not matching state must be rejected" - ); - - Ok(()) - }) - .await - } - - /// MUST accept pushes to refs/nostr/ - /// - /// Spec: GRASP-01, Line 42-44 - /// > MUST accept pushes via this service to `refs/nostr/` but - /// > SHOULD reject if event exists on relay listing a different tip - async fn test_accepts_nostr_refs(ctx: &TestContext) -> TestResult { - TestResult::new( - "accepts_nostr_refs", - "GRASP-01:42-44", - "MUST accept pushes to refs/nostr/", - ) - .run(async { - let (announcement, _) = ctx.create_repo_with_state().build().await?; - - // Create a PR event - let pr_event = ctx.create_pr_event() - .for_repo(&announcement) - .build() - .await?; - - let event_id = pr_event.id(); - - // Push to refs/nostr/ - let result = ctx.git_push( - &announcement, - &format!("refs/nostr/{}", event_id), - "commit-sha..." - ).await?; - - assert!( - result.success, - "Push to refs/nostr/ must succeed" - ); - - Ok(()) - }) - .await - } - - /// MUST reject pr/* branches - /// - /// Spec: GRASP-01, Line 42-44 (implied) - /// PRs should use refs/nostr/, not refs/heads/pr/* - async fn test_rejects_pr_branches(ctx: &TestContext) -> TestResult { - TestResult::new( - "rejects_pr_branches", - "GRASP-01:42-44", - "MUST reject refs/heads/pr/* (use refs/nostr/ instead)", - ) - .run(async { - let (announcement, _) = ctx.create_repo_with_state().build().await?; - - // Try to push to pr/* branch - let result = ctx.git_push( - &announcement, - "refs/heads/pr/123", - "commit-sha..." - ).await; - - assert!( - result.is_err() || !result.unwrap().success, - "Push to refs/heads/pr/* must be rejected" - ); - - Ok(()) - }) - .await - } - - /// MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want - /// - /// Spec: GRASP-01, Line 48-49 - /// > MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` - /// > in advertisement and serve available oids. - async fn test_allows_tip_sha1_in_want(ctx: &TestContext) -> TestResult { - TestResult::new( - "allows_tip_sha1_in_want", - "GRASP-01:48-49", - "MUST advertise and support allow-tip-sha1-in-want", - ) - .run(async { - let (announcement, _) = ctx.create_repo_with_state() - .branch("main", "a1b2c3d4...") - .build() - .await?; - - // Fetch git capabilities - let caps = ctx.git_capabilities(&announcement).await?; - - assert!( - caps.contains("allow-tip-sha1-in-want"), - "Git advertisement must include allow-tip-sha1-in-want" - ); - - assert!( - caps.contains("allow-reachable-sha1-in-want"), - "Git advertisement must include allow-reachable-sha1-in-want" - ); - - Ok(()) - }) - .await - } +// From tests/common/relay.rs + +/// Test relay fixture that manages relay lifecycle +/// +/// Automatically starts and stops the ngit-grasp relay for testing. +/// Uses a random port to avoid conflicts and cleans up created repositories. +pub struct TestRelay { + process: Child, + url: String, + port: u16, +} + +impl TestRelay { + /// Start a test relay instance + pub async fn start() -> Self { ... } - // ================================================================ - // CORS SUPPORT TESTS - // ================================================================ + /// Get the relay WebSocket URL + pub fn url(&self) -> &str { ... } - /// MUST set Access-Control-Allow-Origin: * on ALL responses - /// - /// Spec: GRASP-01, Line 57 - /// > 1. Set `Access-Control-Allow-Origin: *` on ALL responses - async fn test_cors_allow_origin(ctx: &TestContext) -> TestResult { - TestResult::new( - "cors_allow_origin", - "GRASP-01:57", - "MUST set Access-Control-Allow-Origin: * on ALL responses", - ) - .run(async { - let paths = vec![ - "/", - "/test-npub/test-repo.git/info/refs?service=git-upload-pack", - ]; - - for path in paths { - let response = ctx.http_get(path).await?; - - assert_eq!( - response.headers().get("access-control-allow-origin").unwrap(), - "*", - "Path {} must have Access-Control-Allow-Origin: *", path - ); - } - - Ok(()) - }) - .await - } + /// Get the relay domain (host:port) + pub fn domain(&self) -> String { ... } - /// MUST respond to OPTIONS requests with 204 No Content - /// - /// Spec: GRASP-01, Line 60 - /// > 4. Respond to OPTIONS requests with 204 No Content - async fn test_cors_options_request(ctx: &TestContext) -> TestResult { - TestResult::new( - "cors_options_request", - "GRASP-01:60", - "MUST respond to OPTIONS with 204 No Content", - ) - .run(async { - let response = ctx.http_options("/test-npub/test-repo.git/info/refs").await?; - - assert_eq!( - response.status(), 204, - "OPTIONS request must return 204 No Content" - ); - - Ok(()) - }) - .await - } + /// Stop the relay + pub async fn stop(mut self) { ... } } ``` -### Test Result Reporting +### Using TestRelay in Integration Tests + +From [`tests/nip01_compliance.rs`](tests/nip01_compliance.rs): ```rust -/// Test result with spec citation -pub struct TestResult { - pub name: String, - pub spec_ref: String, // e.g., "GRASP-01:12-13" - pub requirement: String, // Exact text from spec - pub passed: bool, - pub error: Option, - pub duration: Duration, -} +use common::TestRelay; +use grasp_audit::*; -impl TestResult { - /// Create a new test result - pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { - TestResult { - name: name.to_string(), - spec_ref: spec_ref.to_string(), - requirement: requirement.to_string(), - passed: false, - error: None, - duration: Duration::default(), - } - } - - /// Run the test - pub async fn run(mut self, test_fn: F) -> Self - where - F: FnOnce() -> Fut, - Fut: Future>, - { - let start = Instant::now(); - - match test_fn().await { - Ok(()) => { - self.passed = true; - } - Err(e) => { - self.passed = false; - self.error = Some(e); - } - } - - self.duration = start.elapsed(); - self - } -} +/// Macro to generate isolated integration tests +macro_rules! isolated_test { + ($test_name:ident) => { + #[tokio::test] + async fn $test_name() { + let relay = TestRelay::start().await; + let config = AuditConfig::isolated(); + let client = AuditClient::new(relay.url(), config) + .await + .expect("Failed to create audit client"); -/// Collection of test results for a spec -pub struct ComplianceResult { - pub spec: String, - pub results: Vec, -} + let result = specs::Nip01SmokeTests::$test_name(&client).await; + + relay.stop().await; -impl ComplianceResult { - pub fn report(&self) -> String { - let mut output = String::new(); - - output.push_str(&format!("\n{} Compliance Report\n", self.spec)); - output.push_str(&"=".repeat(60)); - output.push_str("\n\n"); - - let passed = self.results.iter().filter(|r| r.passed).count(); - let total = self.results.len(); - - output.push_str(&format!("Results: {}/{} passed\n\n", passed, total)); - - for result in &self.results { - let status = if result.passed { "✓" } else { "✗" }; - - output.push_str(&format!( - "{} {} ({})\n", - status, result.name, result.spec_ref - )); - - output.push_str(&format!(" Requirement: {}\n", result.requirement)); - - if let Some(error) = &result.error { - output.push_str(&format!(" Error: {}\n", error)); - } - - output.push_str(&format!(" Duration: {:?}\n\n", result.duration)); + assert!( + result.passed, + "{} failed: {}", + stringify!($test_name), + result.error.as_deref().unwrap_or("unknown error") + ); } - - output - } + }; } -``` -### Usage Example - -```rust -// examples/test_implementation.rs - -use grasp_compliance_tests::{TestContext, Grasp01Spec}; - -#[tokio::main] -async fn main() { - // Configure the implementation to test - let ctx = TestContext::builder() - .base_url("http://localhost:8080") - .websocket_url("ws://localhost:8080") - .domain("localhost:8080") - .build(); - - // Run GRASP-01 compliance tests - let results = Grasp01Spec::test_compliance(&ctx).await; - - // Print report - println!("{}", results.report()); - - // Exit with error if any tests failed - if !results.all_passed() { - std::process::exit(1); - } -} +// Generate isolated tests for all NIP-01 smoke tests +isolated_test!(test_websocket_connection); +isolated_test!(test_send_receive_event); +isolated_test!(test_create_subscription); ``` -### Integration with ngit-grasp +### Running Integration Tests -In `ngit-grasp/tests/compliance.rs`: +```bash +# Run all integration tests +cargo test --test '*' -```rust -use grasp_compliance_tests::{TestContext, Grasp01Spec}; +# Run specific test file +cargo test --test nip01_compliance -#[tokio::test] -async fn test_grasp_01_compliance() { - // Start test server - let server = start_test_server().await; - - // Configure test context - let ctx = TestContext::builder() - .base_url(&server.url()) - .websocket_url(&server.ws_url()) - .domain(&server.domain()) - .build(); - - // Run compliance tests - let results = Grasp01Spec::test_compliance(&ctx).await; - - // Assert all tests passed - assert!( - results.all_passed(), - "GRASP-01 compliance failed:\n{}", - results.report() - ); -} +# Run with output +cargo test --test nip01_compliance -- --nocapture ``` -## Unit Testing Strategy +## GRASP Audit Tool (grasp-audit/) -### Git Module Tests +### Purpose -```rust -// src/git/parser.rs tests +The grasp-audit tool is a **reusable GRASP compliance testing library** that can: -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_pkt_line() { - let data = b"0006a\n"; - let (length, payload) = parse_pkt_line(data).unwrap(); - assert_eq!(length, 6); - assert_eq!(payload, b"a\n"); - } - - #[test] - fn test_parse_flush_packet() { - let data = b"0000"; - let result = parse_pkt_line(data).unwrap(); - assert_eq!(result.0, 0); - } - - #[test] - fn test_parse_ref_updates() { - let body = b"00820000000000000000000000000000000000000000 \ - a1b2c3d4e5f6789012345678901234567890abcd \ - refs/heads/main\0 report-status\n\ - 0000"; - - let updates = parse_ref_updates(body).unwrap(); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].ref_name, "refs/heads/main"); - } -} -``` +- Test ngit-grasp for self-validation +- Test any other GRASP implementation (like ngit-relay) +- Run in CI/CD for continuous compliance verification +- Generate compliance reports -### Authorization Module Tests +### Test Suites -```rust -// src/git/authorization.rs tests +From [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs): -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_maintainers_single() { - let events = vec![ - create_test_announcement("alice", "repo1", vec![]), - ]; - - let maintainers = get_maintainers(&events, "alice", "repo1"); - assert_eq!(maintainers, vec!["alice"]); - } - - #[test] - fn test_get_maintainers_recursive() { - let events = vec![ - create_test_announcement("alice", "repo1", vec!["bob"]), - create_test_announcement("bob", "repo1", vec![]), - ]; - - let maintainers = get_maintainers(&events, "alice", "repo1"); - assert!(maintainers.contains(&"alice".to_string())); - assert!(maintainers.contains(&"bob".to_string())); - } - - #[test] - fn test_get_maintainers_circular() { - let events = vec![ - create_test_announcement("alice", "repo1", vec!["bob"]), - create_test_announcement("bob", "repo1", vec!["alice"]), - ]; - - let maintainers = get_maintainers(&events, "alice", "repo1"); - assert_eq!(maintainers.len(), 2); - } - - #[test] - fn test_validate_state_ref_matching() { - let state = RepositoryState { - branches: HashMap::from([ - ("main".into(), "a1b2c3d4...".into()), - ]), - tags: HashMap::new(), - }; - - let update = RefUpdate { - old_oid: "0000...".into(), - new_oid: "a1b2c3d4...".into(), - ref_name: "refs/heads/main".into(), - }; - - assert!(validate_state_ref(&state, &update).is_ok()); - } - - #[test] - fn test_validate_state_ref_mismatch() { - let state = RepositoryState { - branches: HashMap::from([ - ("main".into(), "aaaa1111...".into()), - ]), - tags: HashMap::new(), - }; - - let update = RefUpdate { - old_oid: "0000...".into(), - new_oid: "bbbb2222...".into(), - ref_name: "refs/heads/main".into(), - }; - - assert!(validate_state_ref(&state, &update).is_err()); - } -} -``` +| Suite | Description | Requirements | +|-------|-------------|--------------| +| [`Nip01SmokeTests`](grasp-audit/src/specs/grasp01/nip01_smoke.rs) | Basic NIP-01 relay functionality | WebSocket only | +| [`Nip11DocumentTests`](grasp-audit/src/specs/grasp01/nip11_document.rs) | NIP-11 relay information document | WebSocket only | +| [`EventAcceptancePolicyTests`](grasp-audit/src/specs/grasp01/event_acceptance_policy.rs) | Event acceptance rules | WebSocket only | +| [`CorsTests`](grasp-audit/src/specs/grasp01/cors.rs) | CORS headers on Git HTTP endpoints | git-data-dir | +| [`GitCloneTests`](grasp-audit/src/specs/grasp01/git_clone.rs) | Git clone operations | git-data-dir | +| [`PushAuthorizationTests`](grasp-audit/src/specs/grasp01/push_authorization.rs) | Push authorization | git-data-dir | +| [`RepositoryCreationTests`](grasp-audit/src/specs/grasp01/repository_creation.rs) | Repository creation | git-data-dir | -## Integration Testing Strategy +### Spec Requirements Database -### Repository Lifecycle Tests +From [`grasp-audit/src/specs/grasp01/spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs): ```rust -// tests/integration/repository_lifecycle.rs - -#[tokio::test] -async fn test_repository_creation_on_announcement() { - let app = test_app().await; - - // Send repository announcement - let announcement = create_announcement() - .with_identifier("test-repo") - .with_clone_tag(app.domain()) - .with_relay_tag(app.domain()) - .sign() - .await; - - app.send_event(announcement).await.unwrap(); - - // Wait for async processing - tokio::time::sleep(Duration::from_secs(1)).await; - - // Verify repository was created - let repo_path = app.git_data_path() - .join(announcement.author_npub()) - .join("test-repo.git"); - - assert!(repo_path.exists()); - assert!(repo_path.join("HEAD").exists()); - assert!(repo_path.join("config").exists()); +pub struct SpecRequirement { + pub id: &'static str, // e.g., "GRASP-01:L9" + pub section: &'static str, // e.g., "Nostr Relay" + pub level: RequirementLevel, // MUST, SHOULD, MAY + pub text: &'static str, // Exact text from spec + pub line: u32, // Line number in spec } -#[tokio::test] -async fn test_push_validation_flow() { - let app = test_app().await; - - // Create repository with state - let (announcement, state) = app.create_repo_with_state() - .branch("main", "commit-sha-123") - .build() - .await; - - // Attempt push matching state - let result = app.git_push("main", "commit-sha-123").await; - assert!(result.success); - - // Attempt push NOT matching state - let result = app.git_push("main", "different-sha-456").await; - assert!(!result.success); - assert!(result.stderr.contains("state event")); +pub enum RequirementLevel { + Must, + Should, + May, } ``` -### Multi-Maintainer Tests +### Automatic Cleanup Tags + +All audit events include cleanup tags for production safety (from [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs)): ```rust -#[tokio::test] -async fn test_multi_maintainer_push() { - let app = test_app().await; - - // Alice creates repo, lists Bob as maintainer - let alice_announcement = create_announcement() - .author("alice") - .maintainers(vec!["bob"]) - .build(); - - app.send_event(alice_announcement).await.unwrap(); - - // Bob creates state event - let bob_state = create_state() - .author("bob") - .branch("main", "commit-123") - .build(); - - app.send_event(bob_state).await.unwrap(); - - // Bob's push should succeed - let result = app.git_push_as("bob", "main", "commit-123").await; - assert!(result.success); -} +// Automatically added to EVERY audit event: +["t", "grasp-audit-test-event"] // Marker +["t", "audit-{run_id}"] // Run isolation +["t", "audit-cleanup-after-{unix_timestamp}"] // Cleanup time ``` -## End-to-End Testing +### Running grasp-audit -### Real Git Client Tests +**Testing the reference implementation (ngit-relay):** -```rust -// tests/e2e/git_client.rs - -#[tokio::test] -async fn test_real_git_clone() { - let app = test_app().await; - - // Setup repository - let (announcement, _) = app.create_repo_with_commits() - .commit("Initial commit", "file.txt", "content") - .build() - .await; - - // Clone with real git client - let temp_dir = TempDir::new().unwrap(); - let clone_url = format!( - "http://{}/{}/{}.git", - app.domain(), - announcement.author_npub(), - announcement.identifier() - ); - - let output = Command::new("git") - .args(&["clone", &clone_url]) - .current_dir(&temp_dir) - .output() - .await - .unwrap(); - - assert!(output.status.success()); - assert!(temp_dir.path().join(announcement.identifier()).exists()); -} +```bash +# Use test-ngit-relay.sh for automated relay management +cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test -#[tokio::test] -async fn test_real_git_push() { - let app = test_app().await; - - // Create repository - let (announcement, keys) = app.create_repo().await; - - // Clone it - let temp_dir = TempDir::new().unwrap(); - git_clone(&app, &announcement, &temp_dir).await; - - // Make changes - let repo_dir = temp_dir.path().join(announcement.identifier()); - tokio::fs::write(repo_dir.join("new-file.txt"), "content").await.unwrap(); - - // Commit - git_commit(&repo_dir, "Add new file").await; - - // Send state event for new commit - let new_commit = git_rev_parse(&repo_dir, "HEAD").await; - app.send_state(&announcement, "main", &new_commit, &keys).await; - - // Push - let output = Command::new("git") - .args(&["push", "origin", "main"]) - .current_dir(&repo_dir) - .output() - .await - .unwrap(); - - assert!(output.status.success()); -} +# Or manually: +docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest +cd grasp-audit +RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture ``` -## Performance Testing +**Testing ngit-grasp (the main project):** -### Load Tests +```bash +# Integration tests use TestRelay fixture - just run: +cargo test --test '*' +``` -```rust -// tests/performance/load.rs +## Test Patterns -#[tokio::test] -async fn test_concurrent_pushes() { - let app = test_app().await; - - let num_concurrent = 100; - let mut handles = vec![]; - - for i in 0..num_concurrent { - let app = app.clone(); - let handle = tokio::spawn(async move { - let (announcement, state) = app.create_repo_with_state() - .branch("main", &format!("commit-{}", i)) - .build() - .await; - - app.git_push("main", &format!("commit-{}", i)).await - }); - handles.push(handle); - } - - let results = futures::future::join_all(handles).await; - - // All should succeed - for result in results { - assert!(result.unwrap().success); - } -} +### Isolated Test Pattern + +Each test runs with its own fresh relay instance: +```rust #[tokio::test] -async fn test_event_ingestion_throughput() { - let app = test_app().await; - - let num_events = 1000; - let start = Instant::now(); - - for i in 0..num_events { - let event = create_announcement() - .with_identifier(&format!("repo-{}", i)) - .build(); - app.send_event(event).await.unwrap(); - } +async fn test_something() { + // Start fresh relay + let relay = TestRelay::start().await; - let duration = start.elapsed(); - let throughput = num_events as f64 / duration.as_secs_f64(); + // Run test + let client = AuditClient::new(relay.url(), AuditConfig::isolated()).await?; + // ... test logic ... - println!("Event throughput: {:.2} events/sec", throughput); - assert!(throughput > 100.0, "Throughput too low"); + // Cleanup + relay.stop().await; } ``` -## Test Utilities +### Macro-Based Test Generation -### Test Fixtures +For test suites that follow the same pattern, use macros: ```rust -// tests/common/fixtures.rs - -pub struct TestEventBuilder { - kind: Kind, - content: String, - tags: Vec, - keys: Option, -} - -impl TestEventBuilder { - pub fn announcement() -> Self { - TestEventBuilder { - kind: Kind::RepositoryAnnouncement, - content: String::new(), - tags: vec![], - keys: None, +macro_rules! isolated_test { + ($test_name:ident) => { + #[tokio::test] + async fn $test_name() { + let relay = TestRelay::start().await; + // ... standard setup and teardown ... } - } - - pub fn with_identifier(mut self, id: &str) -> Self { - self.tags.push(Tag::Identifier(id.to_string())); - self - } - - pub fn with_clone_tag(mut self, url: &str) -> Self { - self.tags.push(Tag::new("clone", vec![url])); - self - } - - pub async fn build(self) -> Event { - let keys = self.keys.unwrap_or_else(|| Keys::generate()); - EventBuilder::new(self.kind, self.content, self.tags) - .to_event(&keys) - .await - .unwrap() - } -} -``` - -### Test Server - -```rust -// tests/common/server.rs - -pub struct TestServer { - addr: SocketAddr, - handle: JoinHandle<()>, -} - -impl TestServer { - pub async fn start() -> Self { - let config = Config { - domain: "localhost:0".to_string(), - git_data_path: TempDir::new().unwrap().into_path(), - relay_data_path: TempDir::new().unwrap().into_path(), - // ... other config - }; - - let app = create_app(config).await; - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - // Wait for server to be ready - tokio::time::sleep(Duration::from_millis(100)).await; - - TestServer { addr, handle } - } - - pub fn url(&self) -> String { - format!("http://{}", self.addr) - } - - pub fn ws_url(&self) -> String { - format!("ws://{}", self.addr) - } + }; } -``` -## CI/CD Integration - -### GitHub Actions Workflow - -```yaml -# .github/workflows/test.yml - -name: Test - -on: [push, pull_request] - -jobs: - unit-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - name: Run unit tests - run: cargo test --lib - - integration-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - name: Install Git - run: sudo apt-get install -y git - - name: Run integration tests - run: cargo test --test '*' - - compliance-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - name: Run GRASP-01 compliance tests - run: cargo test --test compliance - - name: Generate compliance report - run: cargo run --example compliance-report > compliance-report.txt - - name: Upload compliance report - uses: actions/upload-artifact@v3 - with: - name: compliance-report - path: compliance-report.txt +isolated_test!(test_websocket_connection); +isolated_test!(test_send_receive_event); ``` -## Test Coverage +## Coverage Targets -### Target Coverage +| Test Type | Coverage Target | +|-----------|-----------------| +| Unit Tests | >80% line coverage of `src/` | +| Integration Tests | All critical user paths | +| GRASP-01 Compliance | 100% of MUST requirements | -- **Unit Tests**: >80% line coverage -- **Integration Tests**: All critical paths -- **Compliance Tests**: 100% of GRASP-01 requirements -- **E2E Tests**: Key user workflows +## CI/CD Integration -### Measuring Coverage +### Running All Tests ```bash -# Install tarpaulin -cargo install cargo-tarpaulin +# Unit tests (fast, no external dependencies) +cargo test --lib -# Run with coverage -cargo tarpaulin --out Html --output-dir coverage +# Integration tests (requires relay binary built) +cargo build --release +cargo test --test '*' -# View report -open coverage/index.html -``` - -## Documentation Testing - -### Doc Tests - -```rust -/// Parse a pkt-line from Git protocol -/// -/// # Examples -/// -/// ``` -/// use ngit_grasp::git::parse_pkt_line; -/// -/// let data = b"0006a\n"; -/// let (length, payload) = parse_pkt_line(data).unwrap(); -/// assert_eq!(length, 6); -/// assert_eq!(payload, b"a\n"); -/// ``` -pub fn parse_pkt_line(data: &[u8]) -> Result<(usize, &[u8])> { - // implementation -} +# Compliance tests against ngit-relay reference +cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test ``` ## Summary -This comprehensive test strategy ensures: +| What | Where | Purpose | +|------|-------|---------| +| Unit tests | `src/**/tests` modules | Test individual functions | +| Integration tests | `tests/*.rs` | Test ngit-grasp as a whole | +| TestRelay fixture | [`tests/common/relay.rs`](tests/common/relay.rs) | Manage relay lifecycle | +| GRASP audit library | `grasp-audit/` | Reusable compliance testing | +| GRASP-01 specs | [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/) | Spec requirement tests | -1. **Spec Compliance**: Every GRASP requirement has a corresponding test -2. **Reusability**: Compliance tests can validate any GRASP implementation -3. **Clear Failures**: Test failures cite exact spec lines -4. **Comprehensive Coverage**: Unit, integration, compliance, and E2E tests -5. **Maintainability**: Tests mirror spec structure for easy updates +## Related Documentation -The compliance testing tool is a standalone crate that can be: -- Used by ngit-grasp for self-validation -- Published for other GRASP implementations to use -- Updated as new GRASP specs are released (GRASP-02, GRASP-05) -- Run in CI/CD for continuous compliance verification +- [Architecture](../explanation/architecture.md) - System design +- [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons +- [GRASP Audit Learnings](../learnings/grasp-audit.md) - Audit tool patterns \ No newline at end of file -- cgit v1.2.3