From 52bad9954cdddf55ab749fd0c6387edbc766632f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 4 Nov 2025 10:25:53 +0000 Subject: docs: use Diátaxis structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/README.md | 201 +++++++ docs/reference/configuration.md | 434 ++++++++++++++ docs/reference/git-protocol.md | 435 ++++++++++++++ docs/reference/test-strategy.md | 1238 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 2308 insertions(+) create mode 100644 docs/reference/README.md create mode 100644 docs/reference/configuration.md create mode 100644 docs/reference/git-protocol.md create mode 100644 docs/reference/test-strategy.md (limited to 'docs/reference') diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 0000000..96fc5ed --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,201 @@ +# Reference + +**Information-oriented documentation** - Technical details and specifications. + +--- + +## What Is Reference Documentation? + +Reference documentation provides **factual, technical information** that you look up when needed. + +**Characteristics:** +- ✅ Information-oriented (facts and data) +- ✅ Comprehensive and accurate +- ✅ Structured for lookup +- ✅ Dry and to-the-point +- ✅ Maintained as code changes + +**Not reference:** +- ❌ Learning materials (those are Tutorials) +- ❌ Problem-solving guides (those are How-To) +- ❌ Conceptual explanations (those are Explanation) + +--- + +## Available Reference Documentation + +### [Configuration](configuration.md) +**Complete reference for all configuration options** + +**Contents:** +- Environment variables +- Configuration file format +- Validation rules +- Examples for development/production/testing + +**Use when:** You need to know what a config option does or what values are valid + +--- + +### [Git Protocol](git-protocol.md) +**Git Smart HTTP protocol specification** + +**Contents:** +- Protocol overview +- Pkt-line format +- Request/response structure +- Reference updates format +- Parsing examples + +**Use when:** You need to understand Git HTTP internals + +--- + +### [Test Strategy](test-strategy.md) +**Testing approach and compliance framework** + +**Contents:** +- Test categories (unit, integration, compliance) +- GRASP compliance requirements +- Test isolation strategy +- Running tests +- Coverage requirements + +**Use when:** You're writing tests or need to understand test structure + +--- + +## Planned Reference Documentation + +### GRASP Protocol +**Status:** 🔜 Planned + +**Contents:** +- GRASP-01 requirements +- GRASP-02 (Proactive Sync) +- GRASP-05 (Archive) +- Event formats +- Validation rules + +--- + +### API Reference +**Status:** 🔜 Planned (waiting for main server) + +**Contents:** +- HTTP endpoints +- Request/response formats +- Error codes +- Authentication +- Rate limiting + +--- + +### nostr-sdk Upgrade Guide +**Status:** 🔜 Planned + +**Contents:** +- Version compatibility matrix +- Breaking changes by version +- Migration examples +- Common patterns + +--- + +### Event Formats +**Status:** 🔜 Planned + +**Contents:** +- NIP-34 repository announcements (kind 30317) +- NIP-34 state events (kind 30318) +- Custom tags +- Validation rules + +--- + +### CLI Reference +**Status:** 🔜 Planned + +**Contents:** +- Command-line arguments +- Subcommands +- Environment variables +- Exit codes + +--- + +## How to Use Reference Documentation + +1. **Know what you're looking for** - Reference is for lookup, not learning +2. **Use search or table of contents** - Find the specific detail you need +3. **Check version** - Ensure docs match your version +4. **Verify with code** - Reference should match implementation + +**Not sure if this is what you need?** +- New to the topic? → [Tutorials](../tutorials/) +- Trying to solve a problem? → [How-To Guides](../how-to/) +- Want to understand concepts? → [Explanation](../explanation/) + +--- + +## Contributing Reference Documentation + +When writing reference documentation: + +**DO:** +- ✅ Be accurate and complete +- ✅ Use consistent structure +- ✅ Include all options/parameters +- ✅ Provide examples +- ✅ Update when code changes +- ✅ Use tables for structured data + +**DON'T:** +- ❌ Explain concepts (link to Explanation) +- ❌ Provide tutorials (link to Tutorials) +- ❌ Solve problems (link to How-To) +- ❌ Include opinions or recommendations + +**Template:** +```markdown +# Reference: [Topic] + +**Purpose:** [What this reference covers] +**Audience:** [Who needs this information] + +--- + +## Overview + +[Brief description of what's being documented] + +--- + +## [Section 1] + +### [Item] + +**Description:** [What it is/does] +**Type:** [Data type] +**Default:** [Default value] +**Required:** [Yes/No] + +**Examples:** +\`\`\` +[Example usage] +\`\`\` + +**Notes:** +- [Important details] + +--- + +## Related Documentation +- [Links to relevant docs] +``` + +See [Diátaxis: Reference](https://diataxis.fr/reference/) for detailed guidance. + +--- + +*Part of the [ngit-grasp documentation](../README.md) using the [Diátaxis](https://diataxis.fr/) framework.* diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..fc7bbe0 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,434 @@ +# Reference: Configuration + +**Purpose:** Complete reference for all ngit-grasp configuration options +**Audience:** Operators and developers + +--- + +## Configuration Methods + +ngit-grasp can be configured via: + +1. **Environment variables** (recommended for deployment) +2. **`.env` file** (recommended for development) +3. **Command-line arguments** (planned, not yet implemented) + +Configuration is loaded at startup and validated before the server starts. + +--- + +## Environment Variables + +### Server Configuration + +#### `NGIT_BIND_ADDRESS` + +**Description:** Address and port for the HTTP server to bind to +**Type:** String (IP:PORT format) +**Default:** `127.0.0.1:8080` +**Required:** No + +**Examples:** +```bash +# Localhost only (development) +NGIT_BIND_ADDRESS=127.0.0.1:8080 + +# All interfaces (production) +NGIT_BIND_ADDRESS=0.0.0.0:8080 + +# IPv6 +NGIT_BIND_ADDRESS=[::1]:8080 + +# Custom port +NGIT_BIND_ADDRESS=127.0.0.1:3000 +``` + +**Notes:** +- Use `127.0.0.1` for local development +- Use `0.0.0.0` for production (behind reverse proxy) +- Ensure firewall rules allow the port + +--- + +#### `NGIT_DOMAIN` + +**Description:** Public domain name for this GRASP instance +**Type:** String (domain name) +**Default:** None +**Required:** Yes + +**Examples:** +```bash +NGIT_DOMAIN=gitnostr.com +NGIT_DOMAIN=git.example.org +NGIT_DOMAIN=localhost:8080 # Development only +``` + +**Used for:** +- NIP-11 relay information document +- Generating repository URLs +- CORS configuration +- Webhook URLs (future) + +**Notes:** +- Must be accessible from the internet for production +- Include port if non-standard (e.g., `localhost:8080`) +- Used in repository clone URLs: `https://{NGIT_DOMAIN}/{npub}/{repo}.git` + +--- + +### Nostr Relay Configuration + +#### `NGIT_OWNER_NPUB` + +**Description:** Nostr public key (npub format) of the relay operator +**Type:** String (npub1... format) +**Default:** None +**Required:** Yes + +**Examples:** +```bash +NGIT_OWNER_NPUB=npub1alice... +``` + +**Used for:** +- NIP-11 relay information document +- Contact information +- Administrative operations (future) + +**Notes:** +- Must be valid npub format (starts with `npub1`) +- Can be generated with Nostr tools +- Publicly visible in relay metadata + +--- + +#### `NGIT_RELAY_NAME` + +**Description:** Human-readable name for this relay +**Type:** String +**Default:** `"ngit-grasp relay"` +**Required:** No + +**Examples:** +```bash +NGIT_RELAY_NAME="GitNostr Community Relay" +NGIT_RELAY_NAME="Alice's GRASP Server" +``` + +**Used for:** +- NIP-11 relay information document +- Client display +- Relay discovery + +--- + +#### `NGIT_RELAY_DESCRIPTION` + +**Description:** Description of this relay's purpose and policies +**Type:** String +**Default:** `"A GRASP-compliant Git relay"` +**Required:** No + +**Examples:** +```bash +NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects" +NGIT_RELAY_DESCRIPTION="Private relay for ACME Corp repositories" +``` + +**Used for:** +- NIP-11 relay information document +- User information +- Relay selection + +--- + +### Storage Configuration + +#### `NGIT_GIT_DATA_PATH` + +**Description:** Directory path for storing Git repositories +**Type:** String (filesystem path) +**Default:** `./data/git` +**Required:** No + +**Examples:** +```bash +# Relative path (development) +NGIT_GIT_DATA_PATH=./data/git + +# Absolute path (production) +NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git + +# Custom location +NGIT_GIT_DATA_PATH=/mnt/storage/git-repos +``` + +**Storage structure:** +``` +{NGIT_GIT_DATA_PATH}/ + ├── {npub1}/ + │ ├── {repo1}.git/ + │ │ ├── objects/ + │ │ ├── refs/ + │ │ └── ... + │ └── {repo2}.git/ + └── {npub2}/ + └── ... +``` + +**Notes:** +- Directory must be writable by ngit-grasp process +- Ensure sufficient disk space +- Consider backup strategy +- Use fast storage for better performance + +--- + +#### `NGIT_RELAY_DATA_PATH` + +**Description:** Directory path for storing Nostr events and relay data +**Type:** String (filesystem path) +**Default:** `./data/relay` +**Required:** No + +**Examples:** +```bash +# Relative path (development) +NGIT_RELAY_DATA_PATH=./data/relay + +# Absolute path (production) +NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay + +# Separate disk +NGIT_RELAY_DATA_PATH=/mnt/ssd/relay-data +``` + +**Storage structure:** +``` +{NGIT_RELAY_DATA_PATH}/ + ├── events/ + │ └── {event-id}.json + ├── indexes/ + │ ├── by-kind/ + │ ├── by-author/ + │ └── by-tag/ + └── metadata/ +``` + +**Notes:** +- Directory must be writable +- Consider SSD for better query performance +- Size grows with event count +- Implement retention policy for production + +--- + +### Logging Configuration + +#### `RUST_LOG` + +**Description:** Logging level and filters (standard Rust environment variable) +**Type:** String (log level or filter) +**Default:** `info` +**Required:** No + +**Examples:** +```bash +# Simple levels +RUST_LOG=error # Errors only +RUST_LOG=warn # Warnings and errors +RUST_LOG=info # Info, warnings, errors +RUST_LOG=debug # Debug and above +RUST_LOG=trace # Everything + +# Module-specific +RUST_LOG=ngit_grasp=debug,actix_web=info + +# Complex filters +RUST_LOG=debug,hyper=info,tokio=warn +``` + +**Log levels (most to least verbose):** +1. `trace` - Very detailed, performance impact +2. `debug` - Detailed debugging information +3. `info` - General information (default) +4. `warn` - Warnings about potential issues +5. `error` - Errors only + +**Production recommendation:** +```bash +RUST_LOG=info,ngit_grasp=debug +``` + +--- + +### Security Configuration (Planned) + +#### `NGIT_AUTH_REQUIRED` + +**Description:** Require authentication for all operations +**Type:** Boolean +**Default:** `false` +**Status:** 🔜 Planned + +**Examples:** +```bash +NGIT_AUTH_REQUIRED=true # Require auth +NGIT_AUTH_REQUIRED=false # Public relay +``` + +--- + +#### `NGIT_RATE_LIMIT_ENABLED` + +**Description:** Enable rate limiting +**Type:** Boolean +**Default:** `true` +**Status:** 🔜 Planned + +**Examples:** +```bash +NGIT_RATE_LIMIT_ENABLED=true +NGIT_RATE_LIMIT_ENABLED=false +``` + +--- + +## Configuration File (.env) + +For development, create a `.env` file in the project root: + +```bash +# .env file example +NGIT_DOMAIN=localhost:8080 +NGIT_OWNER_NPUB=npub1alice... +NGIT_RELAY_NAME="Development Relay" +NGIT_RELAY_DESCRIPTION="Local development instance" +NGIT_GIT_DATA_PATH=./data/git +NGIT_RELAY_DATA_PATH=./data/relay +NGIT_BIND_ADDRESS=127.0.0.1:8080 +RUST_LOG=debug +``` + +**Notes:** +- Never commit `.env` to version control +- Use `.env.example` as a template +- Environment variables override `.env` values + +--- + +## Validation + +Configuration is validated at startup: + +```rust +// Example validation errors: +Error: Invalid configuration + - NGIT_DOMAIN is required + - NGIT_OWNER_NPUB must start with 'npub1' + - NGIT_GIT_DATA_PATH is not writable +``` + +**Validation checks:** +- Required fields are present +- Values have correct format +- Paths are accessible and writable +- Ports are available +- npub keys are valid + +--- + +## Production Configuration Example + +```bash +# Production .env +NGIT_DOMAIN=gitnostr.com +NGIT_OWNER_NPUB=npub1alice... +NGIT_RELAY_NAME="GitNostr Public Relay" +NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects" +NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git +NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay +NGIT_BIND_ADDRESS=0.0.0.0:8080 +RUST_LOG=info,ngit_grasp=debug +``` + +**Additional production considerations:** +- Use reverse proxy (nginx, Caddy) for HTTPS +- Set up log rotation +- Configure monitoring +- Implement backup strategy +- Use dedicated user account +- Set file permissions properly + +--- + +## Development Configuration Example + +```bash +# Development .env +NGIT_DOMAIN=localhost:8080 +NGIT_OWNER_NPUB=npub1test... +NGIT_RELAY_NAME="Dev Relay" +NGIT_RELAY_DESCRIPTION="Local development" +NGIT_GIT_DATA_PATH=./data/git +NGIT_RELAY_DATA_PATH=./data/relay +NGIT_BIND_ADDRESS=127.0.0.1:8080 +RUST_LOG=debug +``` + +--- + +## Testing Configuration Example + +```bash +# Testing .env +NGIT_DOMAIN=localhost:9999 +NGIT_OWNER_NPUB=npub1test... +NGIT_RELAY_NAME="Test Relay" +NGIT_RELAY_DESCRIPTION="Automated testing" +NGIT_GIT_DATA_PATH=/tmp/ngit-test/git +NGIT_RELAY_DATA_PATH=/tmp/ngit-test/relay +NGIT_BIND_ADDRESS=127.0.0.1:9999 +RUST_LOG=debug +``` + +**Testing notes:** +- Use temporary directories +- Use non-standard ports +- Clean up after tests +- Isolate from development data + +--- + +## Configuration Priority + +When multiple configuration sources exist: + +1. **Command-line arguments** (highest priority, planned) +2. **Environment variables** +3. **`.env` file** +4. **Default values** (lowest priority) + +**Example:** +```bash +# .env file +NGIT_BIND_ADDRESS=127.0.0.1:8080 + +# Environment variable (overrides .env) +NGIT_BIND_ADDRESS=0.0.0.0:3000 cargo run + +# Result: binds to 0.0.0.0:3000 +``` + +--- + +## Related Documentation + +- [Deployment How-To](../how-to/deploy.md) - Production deployment +- [Getting Started Tutorial](../tutorials/getting-started.md) - Initial setup +- [Architecture Overview](../explanation/architecture.md) - System design + +--- + +*Part of the [ngit-grasp reference documentation](./)* diff --git a/docs/reference/git-protocol.md b/docs/reference/git-protocol.md new file mode 100644 index 0000000..172a7bc --- /dev/null +++ b/docs/reference/git-protocol.md @@ -0,0 +1,435 @@ +# Git Smart HTTP Protocol Reference + +## Overview + +This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. + +## Protocol Flow + +### Clone/Fetch (Upload Pack) + +``` +1. Client → GET /repo.git/info/refs?service=git-upload-pack + Server → 200 OK with pack advertisement + +2. Client → POST /repo.git/git-upload-pack + Body: want/have negotiation + Server → 200 OK with pack stream +``` + +**Authorization**: Not needed for public repositories. For GRASP-01, all repos are public. + +### Push (Receive Pack) + +``` +1. Client → GET /repo.git/info/refs?service=git-receive-pack + Server → 200 OK with ref advertisement + +2. Client → POST /repo.git/git-receive-pack + Body: ref updates + pack data + Server → 200 OK with status +``` + +**Authorization**: THIS IS WHERE WE VALIDATE! Step 2 is where inline auth happens. + +## Receive Pack Request Format + +The POST body to `git-receive-pack` has this structure: + +``` +[ref-updates] +[pack-data] +``` + +### Ref Updates Format + +Each ref update is in **pkt-line** format: + +``` +<4-byte-length> \0\n +<4-byte-length> \n +... +0000 +``` + +**Example** (hex representation): + +``` +00a20000000000000000000000000000000000000000 a1b2c3d4e5f6... refs/heads/main\0 report-status side-band-64k +003f0000000000000000000000000000000000000000 f6e5d4c3b2a1... refs/heads/dev\n +0000 +``` + +### Pkt-line Format + +A pkt-line is: +- 4 hex digits: length of entire line (including the 4 digits) +- Payload data +- `0000` = flush packet (end of section) + +**Length calculation**: +``` +length = 4 (for length itself) + payload.len() +``` + +**Examples**: +``` +"0006a\n" → length=6, payload="a\n" +"0000" → flush packet +"000bfoobar\n" → length=11, payload="foobar\n" +``` + +### Parsing Ref Updates + +```rust +pub struct RefUpdate { + pub old_oid: String, // 40 hex chars + pub new_oid: String, // 40 hex chars + pub ref_name: String, // e.g., "refs/heads/main" +} + +pub fn parse_ref_updates(body: &[u8]) -> Result> { + let mut updates = Vec::new(); + let mut offset = 0; + + loop { + // Read pkt-line length + if offset + 4 > body.len() { + break; + } + + let length_str = std::str::from_utf8(&body[offset..offset+4])?; + let length = u16::from_str_radix(length_str, 16)? as usize; + + // Check for flush packet + if length == 0 { + break; + } + + // Extract payload + let payload_end = offset + length; + if payload_end > body.len() { + return Err(Error::InvalidPktLine); + } + + let payload = &body[offset+4..payload_end]; + + // Parse ref update from payload + // Format: " [\0]\n" + let payload_str = std::str::from_utf8(payload)?; + + // Remove trailing newline + let line = payload_str.trim_end_matches('\n'); + + // Split on null byte (first line has capabilities) + let parts: Vec<&str> = line.split('\0').collect(); + let ref_line = parts[0]; + + // Parse old-oid, new-oid, ref-name + let tokens: Vec<&str> = ref_line.split_whitespace().collect(); + if tokens.len() != 3 { + return Err(Error::InvalidRefUpdate); + } + + updates.push(RefUpdate { + old_oid: tokens[0].to_string(), + new_oid: tokens[1].to_string(), + ref_name: tokens[2].to_string(), + }); + + offset = payload_end; + } + + Ok(updates) +} +``` + +## Special OID Values + +- `0000000000000000000000000000000000000000` (40 zeros) = ref creation +- When `old_oid` is all zeros: creating a new ref +- When `new_oid` is all zeros: deleting a ref + +## Validation Requirements + +For GRASP-01, we must validate: + +### 1. Regular Branches/Tags + +```rust +fn validate_regular_ref( + state: &RepositoryState, + update: &RefUpdate, +) -> Result<()> { + // Extract branch/tag name + let (ref_type, name) = if update.ref_name.starts_with("refs/heads/") { + ("branch", &update.ref_name[11..]) + } else if update.ref_name.starts_with("refs/tags/") { + ("tag", &update.ref_name[10..]) + } else { + return Err(Error::InvalidRefName); + }; + + // Check against state + let expected = if ref_type == "branch" { + state.branches.get(name) + } else { + state.tags.get(name) + }; + + match expected { + Some(oid) if oid == &update.new_oid => Ok(()), + Some(oid) => Err(Error::StateMismatch { + ref_name: update.ref_name.clone(), + expected: oid.clone(), + got: update.new_oid.clone(), + }), + None => Err(Error::RefNotInState(update.ref_name.clone())), + } +} +``` + +### 2. PR Refs (refs/nostr/) + +```rust +fn validate_pr_ref(update: &RefUpdate) -> Result<()> { + // Extract event ID + let event_id = &update.ref_name[11..]; // Skip "refs/nostr/" + + // Validate it's a valid 32-byte hex + if event_id.len() != 64 { + return Err(Error::InvalidEventId); + } + + if !event_id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(Error::InvalidEventId); + } + + // TODO: Could optionally verify event exists on relay + // TODO: Could verify event references this repository + + Ok(()) +} +``` + +### 3. Reject pr/* Branches + +```rust +fn reject_pr_branches(update: &RefUpdate) -> Result<()> { + if update.ref_name.starts_with("refs/heads/pr/") { + return Err(Error::InvalidRef( + "pr/* branches must use refs/nostr/".into() + )); + } + Ok(()) +} +``` + +## Complete Validation Flow + +```rust +pub async fn validate_push( + &self, + npub: &str, + identifier: &str, + ref_updates: Vec, +) -> Result<()> { + // 1. Fetch events from local relay + let events = self.fetch_events(identifier).await?; + + // 2. Get pubkey from npub + let pubkey = decode_npub(npub)?; + + // 3. Get maintainer set (recursive) + let maintainers = get_maintainers(&events, &pubkey, identifier); + if maintainers.is_empty() { + return Err(Error::NoAnnouncement); + } + + // 4. Get latest state from maintainers + let state = get_state_from_maintainers(&events, &maintainers)?; + + // 5. Validate each ref update + for update in ref_updates { + // Check for pr/* branches (reject) + reject_pr_branches(&update)?; + + // Handle refs/nostr/* (allow) + if update.ref_name.starts_with("refs/nostr/") { + validate_pr_ref(&update)?; + continue; + } + + // Validate against state + validate_regular_ref(&state, &update)?; + } + + Ok(()) +} +``` + +## Integration with actix-web + +```rust +pub async fn git_receive_pack( + req: HttpRequest, + mut payload: web::Payload, + state: web::Data, +) -> Result { + // 1. Extract repo info from path + let path = req.path(); + let (npub, identifier) = parse_repo_path(path)?; + + // 2. Check repository exists + if !state.repo_manager.exists(&npub, &identifier).await { + return Ok(HttpResponse::NotFound().body("Repository not found")); + } + + // 3. Read request body (need to buffer for parsing) + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + body.extend_from_slice(&chunk?); + } + + // 4. Parse ref updates from body + let ref_updates = parse_ref_updates(&body)?; + + // 5. VALIDATE! + let validator = PushValidator::new(state.nostr_client.clone()); + if let Err(e) = validator.validate_push(&npub, &identifier, ref_updates).await { + return Ok(HttpResponse::Forbidden() + .content_type("text/plain") + .body(format!("error: {}\n", e))); + } + + // 6. Valid! Spawn git-receive-pack + let repo_path = state.repo_manager.get_path(&npub, &identifier); + let mut cmd = Command::new("git"); + cmd.arg("receive-pack") + .arg("--stateless-rpc") + .arg(&repo_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + + // 7. Write body to git stdin + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(&body).await?; + drop(stdin); + + // 8. Stream git stdout back to client + let stdout = child.stdout.take().unwrap(); + let stream = FramedRead::new(stdout, BytesCodec::new()); + + Ok(HttpResponse::Ok() + .content_type("application/x-git-receive-pack-result") + .streaming(stream)) +} +``` + +## Error Responses + +Git clients expect specific error formats: + +### Success +``` +HTTP/1.1 200 OK +Content-Type: application/x-git-receive-pack-result + +[git output stream] +``` + +### Validation Failure +``` +HTTP/1.1 403 Forbidden +Content-Type: text/plain + +error: cannot push refs/heads/main to a1b2c3d as nostr state event is at f6e5d4c +``` + +The `error:` prefix makes it display nicely in git clients. + +## Testing + +```rust +#[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].old_oid, "0000000000000000000000000000000000000000"); + assert_eq!(updates[0].new_oid, "a1b2c3d4e5f6789012345678901234567890abcd"); + assert_eq!(updates[0].ref_name, "refs/heads/main"); +} + +#[tokio::test] +async fn test_validate_matching_state() { + 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_regular_ref(&state, &update).is_ok()); +} +``` + +## Performance Considerations + +1. **Buffering**: We must buffer the entire request body to parse ref updates. For large pushes, this could be memory-intensive. + + **Mitigation**: Limit max request size (e.g., 100MB) + +2. **Pack Data**: After ref updates, the body contains pack data. We don't need to parse this, just forward it to Git. + + **Optimization**: Could use a streaming parser that only extracts ref updates, then streams the rest + +3. **Validation Speed**: State lookup and validation should be fast. + + **Optimization**: Cache state events with TTL + +## Future Enhancements + +### Streaming Parser + +Instead of buffering entire body: + +```rust +// Read pkt-lines until flush packet +let ref_updates = parse_ref_updates_streaming(&mut payload).await?; + +// Now payload is positioned at pack data +// Stream directly to git without buffering +spawn_git_and_stream(payload, repo_path).await?; +``` + +### Pack Inspection + +For advanced validation (future): + +```rust +// Parse pack header to get object count +let (ref_updates, pack_header) = parse_receive_pack_header(&body)?; + +// Could validate pack contents before accepting +validate_pack_contents(&pack_header)?; +``` + +## References + +- [Git HTTP Protocol Docs](https://git-scm.com/docs/http-protocol) +- [Git Pack Protocol](https://git-scm.com/docs/pack-protocol) +- [Pkt-line Format](https://git-scm.com/docs/protocol-common#_pkt_line_format) diff --git a/docs/reference/test-strategy.md b/docs/reference/test-strategy.md new file mode 100644 index 0000000..cc1d5b0 --- /dev/null +++ b/docs/reference/test-strategy.md @@ -0,0 +1,1238 @@ +# Test Strategy for ngit-grasp + +## 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. + +## Testing Philosophy + +1. **Specification-Driven**: Tests mirror the GRASP 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 + +## Test Pyramid + +``` + ╱╲ + ╱ ╲ + ╱ E2E╲ ~ 10% End-to-end with real Git + ╱──────╲ + ╱ ╲ + ╱Compliance╲ ~ 20% GRASP spec validation + ╱────────────╲ + ╱ ╲ + ╱ Integration ╲ ~ 30% Component interaction + ╱──────────────────╲ + ╱ ╲ + ╱ Unit Tests ╲ ~ 40% Individual functions + ╱────────────────────────╲ +``` + +## GRASP Compliance Testing Tool + +### 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 + +``` +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 +``` + +### Spec-Mirrored Test Structure + +Each GRASP spec document maps to a test module with identical structure: + +```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 + } + + // ================================================================ + // CORS SUPPORT TESTS + // ================================================================ + + /// 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 + } + + /// 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 + } +} +``` + +### Test Result Reporting + +```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, +} + +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 + } +} + +/// Collection of test results for a spec +pub struct ComplianceResult { + pub spec: String, + pub results: Vec, +} + +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)); + } + + 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); + } +} +``` + +### Integration with ngit-grasp + +In `ngit-grasp/tests/compliance.rs`: + +```rust +use grasp_compliance_tests::{TestContext, Grasp01Spec}; + +#[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() + ); +} +``` + +## Unit Testing Strategy + +### Git Module Tests + +```rust +// src/git/parser.rs tests + +#[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"); + } +} +``` + +### Authorization Module Tests + +```rust +// src/git/authorization.rs tests + +#[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()); + } +} +``` + +## Integration Testing Strategy + +### Repository Lifecycle Tests + +```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()); +} + +#[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")); +} +``` + +### Multi-Maintainer Tests + +```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); +} +``` + +## End-to-End Testing + +### Real Git Client Tests + +```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()); +} + +#[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()); +} +``` + +## Performance Testing + +### Load Tests + +```rust +// tests/performance/load.rs + +#[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); + } +} + +#[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(); + } + + let duration = start.elapsed(); + let throughput = num_events as f64 / duration.as_secs_f64(); + + println!("Event throughput: {:.2} events/sec", throughput); + assert!(throughput > 100.0, "Throughput too low"); +} +``` + +## Test Utilities + +### Test Fixtures + +```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, + } + } + + 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 +``` + +## Test Coverage + +### Target Coverage + +- **Unit Tests**: >80% line coverage +- **Integration Tests**: All critical paths +- **Compliance Tests**: 100% of GRASP-01 requirements +- **E2E Tests**: Key user workflows + +### Measuring Coverage + +```bash +# Install tarpaulin +cargo install cargo-tarpaulin + +# Run with coverage +cargo tarpaulin --out Html --output-dir coverage + +# 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 +} +``` + +## Summary + +This comprehensive test strategy ensures: + +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 + +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 -- cgit v1.2.3