upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/docs/reference
diff options
context:
space:
mode:
Diffstat (limited to 'docs/reference')
-rw-r--r--docs/reference/README.md201
-rw-r--r--docs/reference/configuration.md434
-rw-r--r--docs/reference/git-protocol.md435
-rw-r--r--docs/reference/test-strategy.md1238
4 files changed, 2308 insertions, 0 deletions
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 @@
1# Reference
2
3**Information-oriented documentation** - Technical details and specifications.
4
5---
6
7## What Is Reference Documentation?
8
9Reference documentation provides **factual, technical information** that you look up when needed.
10
11**Characteristics:**
12- ✅ Information-oriented (facts and data)
13- ✅ Comprehensive and accurate
14- ✅ Structured for lookup
15- ✅ Dry and to-the-point
16- ✅ Maintained as code changes
17
18**Not reference:**
19- ❌ Learning materials (those are Tutorials)
20- ❌ Problem-solving guides (those are How-To)
21- ❌ Conceptual explanations (those are Explanation)
22
23---
24
25## Available Reference Documentation
26
27### [Configuration](configuration.md)
28**Complete reference for all configuration options**
29
30**Contents:**
31- Environment variables
32- Configuration file format
33- Validation rules
34- Examples for development/production/testing
35
36**Use when:** You need to know what a config option does or what values are valid
37
38---
39
40### [Git Protocol](git-protocol.md)
41**Git Smart HTTP protocol specification**
42
43**Contents:**
44- Protocol overview
45- Pkt-line format
46- Request/response structure
47- Reference updates format
48- Parsing examples
49
50**Use when:** You need to understand Git HTTP internals
51
52---
53
54### [Test Strategy](test-strategy.md)
55**Testing approach and compliance framework**
56
57**Contents:**
58- Test categories (unit, integration, compliance)
59- GRASP compliance requirements
60- Test isolation strategy
61- Running tests
62- Coverage requirements
63
64**Use when:** You're writing tests or need to understand test structure
65
66---
67
68## Planned Reference Documentation
69
70### GRASP Protocol
71**Status:** 🔜 Planned
72
73**Contents:**
74- GRASP-01 requirements
75- GRASP-02 (Proactive Sync)
76- GRASP-05 (Archive)
77- Event formats
78- Validation rules
79
80---
81
82### API Reference
83**Status:** 🔜 Planned (waiting for main server)
84
85**Contents:**
86- HTTP endpoints
87- Request/response formats
88- Error codes
89- Authentication
90- Rate limiting
91
92---
93
94### nostr-sdk Upgrade Guide
95**Status:** 🔜 Planned
96
97**Contents:**
98- Version compatibility matrix
99- Breaking changes by version
100- Migration examples
101- Common patterns
102
103---
104
105### Event Formats
106**Status:** 🔜 Planned
107
108**Contents:**
109- NIP-34 repository announcements (kind 30317)
110- NIP-34 state events (kind 30318)
111- Custom tags
112- Validation rules
113
114---
115
116### CLI Reference
117**Status:** 🔜 Planned
118
119**Contents:**
120- Command-line arguments
121- Subcommands
122- Environment variables
123- Exit codes
124
125---
126
127## How to Use Reference Documentation
128
1291. **Know what you're looking for** - Reference is for lookup, not learning
1302. **Use search or table of contents** - Find the specific detail you need
1313. **Check version** - Ensure docs match your version
1324. **Verify with code** - Reference should match implementation
133
134**Not sure if this is what you need?**
135- New to the topic? → [Tutorials](../tutorials/)
136- Trying to solve a problem? → [How-To Guides](../how-to/)
137- Want to understand concepts? → [Explanation](../explanation/)
138
139---
140
141## Contributing Reference Documentation
142
143When writing reference documentation:
144
145**DO:**
146- ✅ Be accurate and complete
147- ✅ Use consistent structure
148- ✅ Include all options/parameters
149- ✅ Provide examples
150- ✅ Update when code changes
151- ✅ Use tables for structured data
152
153**DON'T:**
154- ❌ Explain concepts (link to Explanation)
155- ❌ Provide tutorials (link to Tutorials)
156- ❌ Solve problems (link to How-To)
157- ❌ Include opinions or recommendations
158
159**Template:**
160```markdown
161# Reference: [Topic]
162
163**Purpose:** [What this reference covers]
164**Audience:** [Who needs this information]
165
166---
167
168## Overview
169
170[Brief description of what's being documented]
171
172---
173
174## [Section 1]
175
176### [Item]
177
178**Description:** [What it is/does]
179**Type:** [Data type]
180**Default:** [Default value]
181**Required:** [Yes/No]
182
183**Examples:**
184\`\`\`
185[Example usage]
186\`\`\`
187
188**Notes:**
189- [Important details]
190
191---
192
193## Related Documentation
194- [Links to relevant docs]
195```
196
197See [Diátaxis: Reference](https://diataxis.fr/reference/) for detailed guidance.
198
199---
200
201*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 @@
1# Reference: Configuration
2
3**Purpose:** Complete reference for all ngit-grasp configuration options
4**Audience:** Operators and developers
5
6---
7
8## Configuration Methods
9
10ngit-grasp can be configured via:
11
121. **Environment variables** (recommended for deployment)
132. **`.env` file** (recommended for development)
143. **Command-line arguments** (planned, not yet implemented)
15
16Configuration is loaded at startup and validated before the server starts.
17
18---
19
20## Environment Variables
21
22### Server Configuration
23
24#### `NGIT_BIND_ADDRESS`
25
26**Description:** Address and port for the HTTP server to bind to
27**Type:** String (IP:PORT format)
28**Default:** `127.0.0.1:8080`
29**Required:** No
30
31**Examples:**
32```bash
33# Localhost only (development)
34NGIT_BIND_ADDRESS=127.0.0.1:8080
35
36# All interfaces (production)
37NGIT_BIND_ADDRESS=0.0.0.0:8080
38
39# IPv6
40NGIT_BIND_ADDRESS=[::1]:8080
41
42# Custom port
43NGIT_BIND_ADDRESS=127.0.0.1:3000
44```
45
46**Notes:**
47- Use `127.0.0.1` for local development
48- Use `0.0.0.0` for production (behind reverse proxy)
49- Ensure firewall rules allow the port
50
51---
52
53#### `NGIT_DOMAIN`
54
55**Description:** Public domain name for this GRASP instance
56**Type:** String (domain name)
57**Default:** None
58**Required:** Yes
59
60**Examples:**
61```bash
62NGIT_DOMAIN=gitnostr.com
63NGIT_DOMAIN=git.example.org
64NGIT_DOMAIN=localhost:8080 # Development only
65```
66
67**Used for:**
68- NIP-11 relay information document
69- Generating repository URLs
70- CORS configuration
71- Webhook URLs (future)
72
73**Notes:**
74- Must be accessible from the internet for production
75- Include port if non-standard (e.g., `localhost:8080`)
76- Used in repository clone URLs: `https://{NGIT_DOMAIN}/{npub}/{repo}.git`
77
78---
79
80### Nostr Relay Configuration
81
82#### `NGIT_OWNER_NPUB`
83
84**Description:** Nostr public key (npub format) of the relay operator
85**Type:** String (npub1... format)
86**Default:** None
87**Required:** Yes
88
89**Examples:**
90```bash
91NGIT_OWNER_NPUB=npub1alice...
92```
93
94**Used for:**
95- NIP-11 relay information document
96- Contact information
97- Administrative operations (future)
98
99**Notes:**
100- Must be valid npub format (starts with `npub1`)
101- Can be generated with Nostr tools
102- Publicly visible in relay metadata
103
104---
105
106#### `NGIT_RELAY_NAME`
107
108**Description:** Human-readable name for this relay
109**Type:** String
110**Default:** `"ngit-grasp relay"`
111**Required:** No
112
113**Examples:**
114```bash
115NGIT_RELAY_NAME="GitNostr Community Relay"
116NGIT_RELAY_NAME="Alice's GRASP Server"
117```
118
119**Used for:**
120- NIP-11 relay information document
121- Client display
122- Relay discovery
123
124---
125
126#### `NGIT_RELAY_DESCRIPTION`
127
128**Description:** Description of this relay's purpose and policies
129**Type:** String
130**Default:** `"A GRASP-compliant Git relay"`
131**Required:** No
132
133**Examples:**
134```bash
135NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects"
136NGIT_RELAY_DESCRIPTION="Private relay for ACME Corp repositories"
137```
138
139**Used for:**
140- NIP-11 relay information document
141- User information
142- Relay selection
143
144---
145
146### Storage Configuration
147
148#### `NGIT_GIT_DATA_PATH`
149
150**Description:** Directory path for storing Git repositories
151**Type:** String (filesystem path)
152**Default:** `./data/git`
153**Required:** No
154
155**Examples:**
156```bash
157# Relative path (development)
158NGIT_GIT_DATA_PATH=./data/git
159
160# Absolute path (production)
161NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git
162
163# Custom location
164NGIT_GIT_DATA_PATH=/mnt/storage/git-repos
165```
166
167**Storage structure:**
168```
169{NGIT_GIT_DATA_PATH}/
170 ├── {npub1}/
171 │ ├── {repo1}.git/
172 │ │ ├── objects/
173 │ │ ├── refs/
174 │ │ └── ...
175 │ └── {repo2}.git/
176 └── {npub2}/
177 └── ...
178```
179
180**Notes:**
181- Directory must be writable by ngit-grasp process
182- Ensure sufficient disk space
183- Consider backup strategy
184- Use fast storage for better performance
185
186---
187
188#### `NGIT_RELAY_DATA_PATH`
189
190**Description:** Directory path for storing Nostr events and relay data
191**Type:** String (filesystem path)
192**Default:** `./data/relay`
193**Required:** No
194
195**Examples:**
196```bash
197# Relative path (development)
198NGIT_RELAY_DATA_PATH=./data/relay
199
200# Absolute path (production)
201NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay
202
203# Separate disk
204NGIT_RELAY_DATA_PATH=/mnt/ssd/relay-data
205```
206
207**Storage structure:**
208```
209{NGIT_RELAY_DATA_PATH}/
210 ├── events/
211 │ └── {event-id}.json
212 ├── indexes/
213 │ ├── by-kind/
214 │ ├── by-author/
215 │ └── by-tag/
216 └── metadata/
217```
218
219**Notes:**
220- Directory must be writable
221- Consider SSD for better query performance
222- Size grows with event count
223- Implement retention policy for production
224
225---
226
227### Logging Configuration
228
229#### `RUST_LOG`
230
231**Description:** Logging level and filters (standard Rust environment variable)
232**Type:** String (log level or filter)
233**Default:** `info`
234**Required:** No
235
236**Examples:**
237```bash
238# Simple levels
239RUST_LOG=error # Errors only
240RUST_LOG=warn # Warnings and errors
241RUST_LOG=info # Info, warnings, errors
242RUST_LOG=debug # Debug and above
243RUST_LOG=trace # Everything
244
245# Module-specific
246RUST_LOG=ngit_grasp=debug,actix_web=info
247
248# Complex filters
249RUST_LOG=debug,hyper=info,tokio=warn
250```
251
252**Log levels (most to least verbose):**
2531. `trace` - Very detailed, performance impact
2542. `debug` - Detailed debugging information
2553. `info` - General information (default)
2564. `warn` - Warnings about potential issues
2575. `error` - Errors only
258
259**Production recommendation:**
260```bash
261RUST_LOG=info,ngit_grasp=debug
262```
263
264---
265
266### Security Configuration (Planned)
267
268#### `NGIT_AUTH_REQUIRED`
269
270**Description:** Require authentication for all operations
271**Type:** Boolean
272**Default:** `false`
273**Status:** 🔜 Planned
274
275**Examples:**
276```bash
277NGIT_AUTH_REQUIRED=true # Require auth
278NGIT_AUTH_REQUIRED=false # Public relay
279```
280
281---
282
283#### `NGIT_RATE_LIMIT_ENABLED`
284
285**Description:** Enable rate limiting
286**Type:** Boolean
287**Default:** `true`
288**Status:** 🔜 Planned
289
290**Examples:**
291```bash
292NGIT_RATE_LIMIT_ENABLED=true
293NGIT_RATE_LIMIT_ENABLED=false
294```
295
296---
297
298## Configuration File (.env)
299
300For development, create a `.env` file in the project root:
301
302```bash
303# .env file example
304NGIT_DOMAIN=localhost:8080
305NGIT_OWNER_NPUB=npub1alice...
306NGIT_RELAY_NAME="Development Relay"
307NGIT_RELAY_DESCRIPTION="Local development instance"
308NGIT_GIT_DATA_PATH=./data/git
309NGIT_RELAY_DATA_PATH=./data/relay
310NGIT_BIND_ADDRESS=127.0.0.1:8080
311RUST_LOG=debug
312```
313
314**Notes:**
315- Never commit `.env` to version control
316- Use `.env.example` as a template
317- Environment variables override `.env` values
318
319---
320
321## Validation
322
323Configuration is validated at startup:
324
325```rust
326// Example validation errors:
327Error: Invalid configuration
328 - NGIT_DOMAIN is required
329 - NGIT_OWNER_NPUB must start with 'npub1'
330 - NGIT_GIT_DATA_PATH is not writable
331```
332
333**Validation checks:**
334- Required fields are present
335- Values have correct format
336- Paths are accessible and writable
337- Ports are available
338- npub keys are valid
339
340---
341
342## Production Configuration Example
343
344```bash
345# Production .env
346NGIT_DOMAIN=gitnostr.com
347NGIT_OWNER_NPUB=npub1alice...
348NGIT_RELAY_NAME="GitNostr Public Relay"
349NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects"
350NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git
351NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay
352NGIT_BIND_ADDRESS=0.0.0.0:8080
353RUST_LOG=info,ngit_grasp=debug
354```
355
356**Additional production considerations:**
357- Use reverse proxy (nginx, Caddy) for HTTPS
358- Set up log rotation
359- Configure monitoring
360- Implement backup strategy
361- Use dedicated user account
362- Set file permissions properly
363
364---
365
366## Development Configuration Example
367
368```bash
369# Development .env
370NGIT_DOMAIN=localhost:8080
371NGIT_OWNER_NPUB=npub1test...
372NGIT_RELAY_NAME="Dev Relay"
373NGIT_RELAY_DESCRIPTION="Local development"
374NGIT_GIT_DATA_PATH=./data/git
375NGIT_RELAY_DATA_PATH=./data/relay
376NGIT_BIND_ADDRESS=127.0.0.1:8080
377RUST_LOG=debug
378```
379
380---
381
382## Testing Configuration Example
383
384```bash
385# Testing .env
386NGIT_DOMAIN=localhost:9999
387NGIT_OWNER_NPUB=npub1test...
388NGIT_RELAY_NAME="Test Relay"
389NGIT_RELAY_DESCRIPTION="Automated testing"
390NGIT_GIT_DATA_PATH=/tmp/ngit-test/git
391NGIT_RELAY_DATA_PATH=/tmp/ngit-test/relay
392NGIT_BIND_ADDRESS=127.0.0.1:9999
393RUST_LOG=debug
394```
395
396**Testing notes:**
397- Use temporary directories
398- Use non-standard ports
399- Clean up after tests
400- Isolate from development data
401
402---
403
404## Configuration Priority
405
406When multiple configuration sources exist:
407
4081. **Command-line arguments** (highest priority, planned)
4092. **Environment variables**
4103. **`.env` file**
4114. **Default values** (lowest priority)
412
413**Example:**
414```bash
415# .env file
416NGIT_BIND_ADDRESS=127.0.0.1:8080
417
418# Environment variable (overrides .env)
419NGIT_BIND_ADDRESS=0.0.0.0:3000 cargo run
420
421# Result: binds to 0.0.0.0:3000
422```
423
424---
425
426## Related Documentation
427
428- [Deployment How-To](../how-to/deploy.md) - Production deployment
429- [Getting Started Tutorial](../tutorials/getting-started.md) - Initial setup
430- [Architecture Overview](../explanation/architecture.md) - System design
431
432---
433
434*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 @@
1# Git Smart HTTP Protocol Reference
2
3## Overview
4
5This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation.
6
7## Protocol Flow
8
9### Clone/Fetch (Upload Pack)
10
11```
121. Client → GET /repo.git/info/refs?service=git-upload-pack
13 Server → 200 OK with pack advertisement
14
152. Client → POST /repo.git/git-upload-pack
16 Body: want/have negotiation
17 Server → 200 OK with pack stream
18```
19
20**Authorization**: Not needed for public repositories. For GRASP-01, all repos are public.
21
22### Push (Receive Pack)
23
24```
251. Client → GET /repo.git/info/refs?service=git-receive-pack
26 Server → 200 OK with ref advertisement
27
282. Client → POST /repo.git/git-receive-pack
29 Body: ref updates + pack data
30 Server → 200 OK with status
31```
32
33**Authorization**: THIS IS WHERE WE VALIDATE! Step 2 is where inline auth happens.
34
35## Receive Pack Request Format
36
37The POST body to `git-receive-pack` has this structure:
38
39```
40[ref-updates]
41[pack-data]
42```
43
44### Ref Updates Format
45
46Each ref update is in **pkt-line** format:
47
48```
49<4-byte-length><old-oid> <new-oid> <ref-name>\0<capabilities>\n
50<4-byte-length><old-oid> <new-oid> <ref-name>\n
51...
520000
53```
54
55**Example** (hex representation):
56
57```
5800a20000000000000000000000000000000000000000 a1b2c3d4e5f6... refs/heads/main\0 report-status side-band-64k
59003f0000000000000000000000000000000000000000 f6e5d4c3b2a1... refs/heads/dev\n
600000
61```
62
63### Pkt-line Format
64
65A pkt-line is:
66- 4 hex digits: length of entire line (including the 4 digits)
67- Payload data
68- `0000` = flush packet (end of section)
69
70**Length calculation**:
71```
72length = 4 (for length itself) + payload.len()
73```
74
75**Examples**:
76```
77"0006a\n" → length=6, payload="a\n"
78"0000" → flush packet
79"000bfoobar\n" → length=11, payload="foobar\n"
80```
81
82### Parsing Ref Updates
83
84```rust
85pub struct RefUpdate {
86 pub old_oid: String, // 40 hex chars
87 pub new_oid: String, // 40 hex chars
88 pub ref_name: String, // e.g., "refs/heads/main"
89}
90
91pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> {
92 let mut updates = Vec::new();
93 let mut offset = 0;
94
95 loop {
96 // Read pkt-line length
97 if offset + 4 > body.len() {
98 break;
99 }
100
101 let length_str = std::str::from_utf8(&body[offset..offset+4])?;
102 let length = u16::from_str_radix(length_str, 16)? as usize;
103
104 // Check for flush packet
105 if length == 0 {
106 break;
107 }
108
109 // Extract payload
110 let payload_end = offset + length;
111 if payload_end > body.len() {
112 return Err(Error::InvalidPktLine);
113 }
114
115 let payload = &body[offset+4..payload_end];
116
117 // Parse ref update from payload
118 // Format: "<old-oid> <new-oid> <ref-name>[\0<capabilities>]\n"
119 let payload_str = std::str::from_utf8(payload)?;
120
121 // Remove trailing newline
122 let line = payload_str.trim_end_matches('\n');
123
124 // Split on null byte (first line has capabilities)
125 let parts: Vec<&str> = line.split('\0').collect();
126 let ref_line = parts[0];
127
128 // Parse old-oid, new-oid, ref-name
129 let tokens: Vec<&str> = ref_line.split_whitespace().collect();
130 if tokens.len() != 3 {
131 return Err(Error::InvalidRefUpdate);
132 }
133
134 updates.push(RefUpdate {
135 old_oid: tokens[0].to_string(),
136 new_oid: tokens[1].to_string(),
137 ref_name: tokens[2].to_string(),
138 });
139
140 offset = payload_end;
141 }
142
143 Ok(updates)
144}
145```
146
147## Special OID Values
148
149- `0000000000000000000000000000000000000000` (40 zeros) = ref creation
150- When `old_oid` is all zeros: creating a new ref
151- When `new_oid` is all zeros: deleting a ref
152
153## Validation Requirements
154
155For GRASP-01, we must validate:
156
157### 1. Regular Branches/Tags
158
159```rust
160fn validate_regular_ref(
161 state: &RepositoryState,
162 update: &RefUpdate,
163) -> Result<()> {
164 // Extract branch/tag name
165 let (ref_type, name) = if update.ref_name.starts_with("refs/heads/") {
166 ("branch", &update.ref_name[11..])
167 } else if update.ref_name.starts_with("refs/tags/") {
168 ("tag", &update.ref_name[10..])
169 } else {
170 return Err(Error::InvalidRefName);
171 };
172
173 // Check against state
174 let expected = if ref_type == "branch" {
175 state.branches.get(name)
176 } else {
177 state.tags.get(name)
178 };
179
180 match expected {
181 Some(oid) if oid == &update.new_oid => Ok(()),
182 Some(oid) => Err(Error::StateMismatch {
183 ref_name: update.ref_name.clone(),
184 expected: oid.clone(),
185 got: update.new_oid.clone(),
186 }),
187 None => Err(Error::RefNotInState(update.ref_name.clone())),
188 }
189}
190```
191
192### 2. PR Refs (refs/nostr/<event-id>)
193
194```rust
195fn validate_pr_ref(update: &RefUpdate) -> Result<()> {
196 // Extract event ID
197 let event_id = &update.ref_name[11..]; // Skip "refs/nostr/"
198
199 // Validate it's a valid 32-byte hex
200 if event_id.len() != 64 {
201 return Err(Error::InvalidEventId);
202 }
203
204 if !event_id.chars().all(|c| c.is_ascii_hexdigit()) {
205 return Err(Error::InvalidEventId);
206 }
207
208 // TODO: Could optionally verify event exists on relay
209 // TODO: Could verify event references this repository
210
211 Ok(())
212}
213```
214
215### 3. Reject pr/* Branches
216
217```rust
218fn reject_pr_branches(update: &RefUpdate) -> Result<()> {
219 if update.ref_name.starts_with("refs/heads/pr/") {
220 return Err(Error::InvalidRef(
221 "pr/* branches must use refs/nostr/<event-id>".into()
222 ));
223 }
224 Ok(())
225}
226```
227
228## Complete Validation Flow
229
230```rust
231pub async fn validate_push(
232 &self,
233 npub: &str,
234 identifier: &str,
235 ref_updates: Vec<RefUpdate>,
236) -> Result<()> {
237 // 1. Fetch events from local relay
238 let events = self.fetch_events(identifier).await?;
239
240 // 2. Get pubkey from npub
241 let pubkey = decode_npub(npub)?;
242
243 // 3. Get maintainer set (recursive)
244 let maintainers = get_maintainers(&events, &pubkey, identifier);
245 if maintainers.is_empty() {
246 return Err(Error::NoAnnouncement);
247 }
248
249 // 4. Get latest state from maintainers
250 let state = get_state_from_maintainers(&events, &maintainers)?;
251
252 // 5. Validate each ref update
253 for update in ref_updates {
254 // Check for pr/* branches (reject)
255 reject_pr_branches(&update)?;
256
257 // Handle refs/nostr/* (allow)
258 if update.ref_name.starts_with("refs/nostr/") {
259 validate_pr_ref(&update)?;
260 continue;
261 }
262
263 // Validate against state
264 validate_regular_ref(&state, &update)?;
265 }
266
267 Ok(())
268}
269```
270
271## Integration with actix-web
272
273```rust
274pub async fn git_receive_pack(
275 req: HttpRequest,
276 mut payload: web::Payload,
277 state: web::Data<AppState>,
278) -> Result<HttpResponse> {
279 // 1. Extract repo info from path
280 let path = req.path();
281 let (npub, identifier) = parse_repo_path(path)?;
282
283 // 2. Check repository exists
284 if !state.repo_manager.exists(&npub, &identifier).await {
285 return Ok(HttpResponse::NotFound().body("Repository not found"));
286 }
287
288 // 3. Read request body (need to buffer for parsing)
289 let mut body = web::BytesMut::new();
290 while let Some(chunk) = payload.next().await {
291 body.extend_from_slice(&chunk?);
292 }
293
294 // 4. Parse ref updates from body
295 let ref_updates = parse_ref_updates(&body)?;
296
297 // 5. VALIDATE!
298 let validator = PushValidator::new(state.nostr_client.clone());
299 if let Err(e) = validator.validate_push(&npub, &identifier, ref_updates).await {
300 return Ok(HttpResponse::Forbidden()
301 .content_type("text/plain")
302 .body(format!("error: {}\n", e)));
303 }
304
305 // 6. Valid! Spawn git-receive-pack
306 let repo_path = state.repo_manager.get_path(&npub, &identifier);
307 let mut cmd = Command::new("git");
308 cmd.arg("receive-pack")
309 .arg("--stateless-rpc")
310 .arg(&repo_path)
311 .stdin(Stdio::piped())
312 .stdout(Stdio::piped())
313 .stderr(Stdio::piped());
314
315 let mut child = cmd.spawn()?;
316
317 // 7. Write body to git stdin
318 let mut stdin = child.stdin.take().unwrap();
319 stdin.write_all(&body).await?;
320 drop(stdin);
321
322 // 8. Stream git stdout back to client
323 let stdout = child.stdout.take().unwrap();
324 let stream = FramedRead::new(stdout, BytesCodec::new());
325
326 Ok(HttpResponse::Ok()
327 .content_type("application/x-git-receive-pack-result")
328 .streaming(stream))
329}
330```
331
332## Error Responses
333
334Git clients expect specific error formats:
335
336### Success
337```
338HTTP/1.1 200 OK
339Content-Type: application/x-git-receive-pack-result
340
341[git output stream]
342```
343
344### Validation Failure
345```
346HTTP/1.1 403 Forbidden
347Content-Type: text/plain
348
349error: cannot push refs/heads/main to a1b2c3d as nostr state event is at f6e5d4c
350```
351
352The `error:` prefix makes it display nicely in git clients.
353
354## Testing
355
356```rust
357#[test]
358fn test_parse_ref_updates() {
359 let body = b"00820000000000000000000000000000000000000000 \
360 a1b2c3d4e5f6789012345678901234567890abcd \
361 refs/heads/main\0 report-status\n\
362 0000";
363
364 let updates = parse_ref_updates(body).unwrap();
365 assert_eq!(updates.len(), 1);
366 assert_eq!(updates[0].old_oid, "0000000000000000000000000000000000000000");
367 assert_eq!(updates[0].new_oid, "a1b2c3d4e5f6789012345678901234567890abcd");
368 assert_eq!(updates[0].ref_name, "refs/heads/main");
369}
370
371#[tokio::test]
372async fn test_validate_matching_state() {
373 let state = RepositoryState {
374 branches: HashMap::from([
375 ("main".into(), "a1b2c3d4...".into()),
376 ]),
377 tags: HashMap::new(),
378 };
379
380 let update = RefUpdate {
381 old_oid: "0000...".into(),
382 new_oid: "a1b2c3d4...".into(),
383 ref_name: "refs/heads/main".into(),
384 };
385
386 assert!(validate_regular_ref(&state, &update).is_ok());
387}
388```
389
390## Performance Considerations
391
3921. **Buffering**: We must buffer the entire request body to parse ref updates. For large pushes, this could be memory-intensive.
393
394 **Mitigation**: Limit max request size (e.g., 100MB)
395
3962. **Pack Data**: After ref updates, the body contains pack data. We don't need to parse this, just forward it to Git.
397
398 **Optimization**: Could use a streaming parser that only extracts ref updates, then streams the rest
399
4003. **Validation Speed**: State lookup and validation should be fast.
401
402 **Optimization**: Cache state events with TTL
403
404## Future Enhancements
405
406### Streaming Parser
407
408Instead of buffering entire body:
409
410```rust
411// Read pkt-lines until flush packet
412let ref_updates = parse_ref_updates_streaming(&mut payload).await?;
413
414// Now payload is positioned at pack data
415// Stream directly to git without buffering
416spawn_git_and_stream(payload, repo_path).await?;
417```
418
419### Pack Inspection
420
421For advanced validation (future):
422
423```rust
424// Parse pack header to get object count
425let (ref_updates, pack_header) = parse_receive_pack_header(&body)?;
426
427// Could validate pack contents before accepting
428validate_pack_contents(&pack_header)?;
429```
430
431## References
432
433- [Git HTTP Protocol Docs](https://git-scm.com/docs/http-protocol)
434- [Git Pack Protocol](https://git-scm.com/docs/pack-protocol)
435- [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 @@
1# Test Strategy for ngit-grasp
2
3## Overview
4
5This 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.
6
7## Testing Philosophy
8
91. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly
102. **Compliance-First**: Every requirement in the spec has a corresponding test
113. **Reusable**: Compliance tests can validate any GRASP implementation
124. **Clear Failures**: Test failures cite exact spec lines/sections
135. **Comprehensive**: Unit, integration, and compliance testing
14
15## Test Pyramid
16
17```
18 ╱╲
19 ╱ ╲
20 ╱ E2E╲ ~ 10% End-to-end with real Git
21 ╱──────╲
22 ╱ ╲
23 ╱Compliance╲ ~ 20% GRASP spec validation
24 ╱────────────╲
25 ╱ ╲
26 ╱ Integration ╲ ~ 30% Component interaction
27 ╱──────────────────╲
28 ╱ ╲
29 ╱ Unit Tests ╲ ~ 40% Individual functions
30 ╱────────────────────────╲
31```
32
33## GRASP Compliance Testing Tool
34
35### Design Goals
36
371. **Reusable**: Can test ngit-grasp or any other GRASP implementation
382. **Spec-Mirrored**: Test structure matches GRASP protocol documents
393. **Clear Reporting**: Failures cite exact spec requirements
404. **Automated**: Can run in CI/CD
415. **Extensible**: Easy to add new GRASP versions (GRASP-02, GRASP-05)
42
43### Project Structure
44
45```
46grasp-compliance-tests/
47├── Cargo.toml # Standalone crate
48├── README.md # Usage instructions
49├── src/
50│ ├── lib.rs # Public API
51│ ├── client.rs # Test client utilities
52│ ├── assertions.rs # Spec-based assertions
53│ └── specs/
54│ ├── mod.rs # Spec registry
55│ ├── grasp_01.rs # GRASP-01 tests
56│ ├── grasp_02.rs # GRASP-02 tests
57│ └── grasp_05.rs # GRASP-05 tests
58├── fixtures/
59│ ├── repos/ # Test repositories
60│ ├── events/ # Nostr event fixtures
61│ └── keys/ # Test keypairs
62└── examples/
63 └── test_implementation.rs # Example usage
64```
65
66### Spec-Mirrored Test Structure
67
68Each GRASP spec document maps to a test module with identical structure:
69
70```rust
71// src/specs/grasp_01.rs
72
73use crate::{TestContext, SpecRequirement, ComplianceResult};
74
75/// GRASP-01 - Core Service Requirements
76/// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
77pub struct Grasp01Spec;
78
79impl Grasp01Spec {
80 /// Run all GRASP-01 compliance tests
81 pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult {
82 let mut results = ComplianceResult::new("GRASP-01");
83
84 // Section: Nostr Relay
85 results.add(Self::test_nostr_relay_nip01_compliance(ctx).await);
86 results.add(Self::test_accepts_repository_announcements(ctx).await);
87 results.add(Self::test_accepts_repository_state_announcements(ctx).await);
88 results.add(Self::test_rejects_unlisted_announcements(ctx).await);
89 results.add(Self::test_accepts_related_events(ctx).await);
90 results.add(Self::test_serves_nip11_document(ctx).await);
91 results.add(Self::test_nip11_has_supported_grasps(ctx).await);
92 results.add(Self::test_nip11_has_repo_acceptance_criteria(ctx).await);
93 results.add(Self::test_nip11_has_curation_policy(ctx).await);
94
95 // Section: Git Smart HTTP Service
96 results.add(Self::test_serves_git_at_correct_path(ctx).await);
97 results.add(Self::test_accepts_matching_pushes(ctx).await);
98 results.add(Self::test_rejects_mismatched_pushes(ctx).await);
99 results.add(Self::test_respects_recursive_maintainers(ctx).await);
100 results.add(Self::test_sets_head_from_state(ctx).await);
101 results.add(Self::test_accepts_nostr_refs(ctx).await);
102 results.add(Self::test_rejects_pr_branches(ctx).await);
103 results.add(Self::test_deletes_orphaned_nostr_refs(ctx).await);
104 results.add(Self::test_allows_reachable_sha1_in_want(ctx).await);
105 results.add(Self::test_allows_tip_sha1_in_want(ctx).await);
106 results.add(Self::test_serves_webpage(ctx).await);
107
108 // Section: CORS Support
109 results.add(Self::test_cors_allow_origin(ctx).await);
110 results.add(Self::test_cors_allow_methods(ctx).await);
111 results.add(Self::test_cors_allow_headers(ctx).await);
112 results.add(Self::test_cors_options_request(ctx).await);
113
114 results
115 }
116
117 // ================================================================
118 // NOSTR RELAY TESTS
119 // ================================================================
120
121 /// MUST serve a NIP-01 compliant nostr relay at `/`
122 ///
123 /// Spec: GRASP-01, Line 9-10
124 /// > MUST serve a [NIP-01](https://nips.nostr.com/1) compliant nostr
125 /// > relay at `/` that accepts [git repository announcements]...
126 async fn test_nostr_relay_nip01_compliance(ctx: &TestContext) -> TestResult {
127 TestResult::new(
128 "nostr_relay_nip01_compliance",
129 "GRASP-01:9-10",
130 "MUST serve a NIP-01 compliant nostr relay at `/`",
131 )
132 .run(async {
133 // Test WebSocket upgrade at /
134 let ws = ctx.connect_websocket("/").await?;
135
136 // Test NIP-01 REQ/EVENT/CLOSE/NOTICE messages
137 ws.send_req("test-sub", vec![]).await?;
138 let response = ws.recv().await?;
139 assert_nip01_eose(response)?;
140
141 Ok(())
142 })
143 .await
144 }
145
146 /// MUST reject announcements that do not list the service in both
147 /// `clone` and `relays` tags unless implementing `GRASP-05`
148 ///
149 /// Spec: GRASP-01, Line 12-13
150 /// > MUST reject [git repository announcements] that do not list the
151 /// > service in both `clone` and `relays` tags unless implementing `GRASP-05`.
152 async fn test_rejects_unlisted_announcements(ctx: &TestContext) -> TestResult {
153 TestResult::new(
154 "rejects_unlisted_announcements",
155 "GRASP-01:12-13",
156 "MUST reject announcements not listing service in clone and relays",
157 )
158 .run(async {
159 let event = ctx.create_announcement()
160 .without_clone_tag(ctx.domain())
161 .build()
162 .await?;
163
164 let result = ctx.send_event(event).await?;
165
166 assert_eq!(
167 result.ok, false,
168 "Expected rejection of announcement without clone tag"
169 );
170 assert!(
171 result.message.contains("clone") || result.message.contains("relays"),
172 "Expected rejection message to mention clone/relays requirement"
173 );
174
175 Ok(())
176 })
177 .await
178 }
179
180 /// MUST accept other events that tag, or are tagged by, accepted announcements
181 ///
182 /// Spec: GRASP-01, Line 17-20
183 /// > MUST accept other events that tag, or are tagged by, either:
184 /// > 1. accepted [git repository announcements]; or
185 /// > 2. accepted [issues] or [patches]
186 async fn test_accepts_related_events(ctx: &TestContext) -> TestResult {
187 TestResult::new(
188 "accepts_related_events",
189 "GRASP-01:17-20",
190 "MUST accept events that tag or are tagged by accepted announcements",
191 )
192 .run(async {
193 // First, create and accept an announcement
194 let announcement = ctx.create_announcement()
195 .with_clone_tag(ctx.domain())
196 .with_relay_tag(ctx.domain())
197 .build()
198 .await?;
199
200 ctx.send_event(announcement.clone()).await?;
201
202 // Now send an issue that tags the announcement
203 let issue = ctx.create_issue()
204 .tag_announcement(&announcement)
205 .build()
206 .await?;
207
208 let result = ctx.send_event(issue).await?;
209
210 assert_eq!(
211 result.ok, true,
212 "Expected acceptance of issue tagging accepted announcement"
213 );
214
215 Ok(())
216 })
217 .await
218 }
219
220 /// MUST serve a NIP-11 document with required fields
221 ///
222 /// Spec: GRASP-01, Line 24-27
223 /// > MUST serve a [NIP-11] document:
224 /// > 1. MUST list each supported GRASP under `supported_grasps`
225 /// > 2. MUST list repository acceptance criteria under `repo_acceptance_criteria`
226 /// > 3. MUST list curation policy under `curation` if events are curated
227 async fn test_serves_nip11_document(ctx: &TestContext) -> TestResult {
228 TestResult::new(
229 "serves_nip11_document",
230 "GRASP-01:24-27",
231 "MUST serve a NIP-11 document",
232 )
233 .run(async {
234 let nip11 = ctx.fetch_nip11().await?;
235
236 assert!(
237 nip11.contains_key("supported_nips"),
238 "NIP-11 document must have supported_nips"
239 );
240
241 Ok(())
242 })
243 .await
244 }
245
246 /// NIP-11 MUST list supported GRASPs
247 ///
248 /// Spec: GRASP-01, Line 25
249 /// > 1. MUST list each supported GRASP under `supported_grasps`
250 /// > in format `GRASP-XX` eg `GRASP-01` as a string array
251 async fn test_nip11_has_supported_grasps(ctx: &TestContext) -> TestResult {
252 TestResult::new(
253 "nip11_has_supported_grasps",
254 "GRASP-01:25",
255 "NIP-11 MUST list supported_grasps as string array",
256 )
257 .run(async {
258 let nip11 = ctx.fetch_nip11().await?;
259
260 let grasps = nip11.get("supported_grasps")
261 .ok_or("NIP-11 missing supported_grasps field")?
262 .as_array()
263 .ok_or("supported_grasps must be an array")?;
264
265 assert!(
266 grasps.iter().any(|g| g.as_str() == Some("GRASP-01")),
267 "supported_grasps must include 'GRASP-01'"
268 );
269
270 // Validate format: GRASP-XX
271 for grasp in grasps {
272 let s = grasp.as_str().ok_or("GRASP must be a string")?;
273 assert!(
274 s.starts_with("GRASP-") && s.len() >= 8,
275 "GRASP format must be 'GRASP-XX', got: {}", s
276 );
277 }
278
279 Ok(())
280 })
281 .await
282 }
283
284 // ================================================================
285 // GIT SMART HTTP SERVICE TESTS
286 // ================================================================
287
288 /// MUST serve a git repository via git smart http at /<npub>/<identifier>.git
289 ///
290 /// Spec: GRASP-01, Line 31-32
291 /// > MUST serve a git repository via an unauthenticated [git smart http service]
292 /// > at `/<npub>/<identifier>.git` for each accepted announcement
293 async fn test_serves_git_at_correct_path(ctx: &TestContext) -> TestResult {
294 TestResult::new(
295 "serves_git_at_correct_path",
296 "GRASP-01:31-32",
297 "MUST serve git at /<npub>/<identifier>.git",
298 )
299 .run(async {
300 // Create and send announcement
301 let announcement = ctx.create_announcement()
302 .with_identifier("test-repo")
303 .with_clone_tag(ctx.domain())
304 .with_relay_tag(ctx.domain())
305 .build()
306 .await?;
307
308 let npub = announcement.author_npub();
309 ctx.send_event(announcement).await?;
310
311 // Wait for repo creation
312 tokio::time::sleep(Duration::from_secs(2)).await;
313
314 // Test git info/refs endpoint
315 let path = format!("/{}/test-repo.git/info/refs?service=git-upload-pack", npub);
316 let response = ctx.http_get(&path).await?;
317
318 assert_eq!(
319 response.status(), 200,
320 "Git info/refs must return 200 OK"
321 );
322
323 assert_eq!(
324 response.headers().get("content-type").unwrap(),
325 "application/x-git-upload-pack-advertisement",
326 "Git info/refs must have correct content-type"
327 );
328
329 Ok(())
330 })
331 .await
332 }
333
334 /// MUST accept pushes that match the latest state announcement
335 ///
336 /// Spec: GRASP-01, Line 34-35
337 /// > MUST accept pushes via this service that match the latest
338 /// > [repo state announcement] on the relay, respecting the recursive maintainer set.
339 async fn test_accepts_matching_pushes(ctx: &TestContext) -> TestResult {
340 TestResult::new(
341 "accepts_matching_pushes",
342 "GRASP-01:34-35",
343 "MUST accept pushes matching latest state announcement",
344 )
345 .run(async {
346 // Setup: Create repo with announcement and state
347 let (announcement, state) = ctx.create_repo_with_state()
348 .branch("main", "a1b2c3d4...")
349 .build()
350 .await?;
351
352 // Push matching state
353 let result = ctx.git_push(&announcement, "main", "a1b2c3d4...").await?;
354
355 assert!(
356 result.success,
357 "Push matching state must succeed, got: {}", result.stderr
358 );
359
360 Ok(())
361 })
362 .await
363 }
364
365 /// MUST reject pushes that don't match the state announcement
366 ///
367 /// Spec: GRASP-01, Line 34-35 (inverse requirement)
368 /// Implied by "MUST accept pushes... that match"
369 async fn test_rejects_mismatched_pushes(ctx: &TestContext) -> TestResult {
370 TestResult::new(
371 "rejects_mismatched_pushes",
372 "GRASP-01:34-35",
373 "MUST reject pushes not matching state announcement",
374 )
375 .run(async {
376 // Setup: Create repo with state pointing to commit A
377 let (announcement, state) = ctx.create_repo_with_state()
378 .branch("main", "aaaa1111...")
379 .build()
380 .await?;
381
382 // Try to push different commit B
383 let result = ctx.git_push(&announcement, "main", "bbbb2222...").await;
384
385 assert!(
386 result.is_err() || !result.unwrap().success,
387 "Push not matching state must be rejected"
388 );
389
390 Ok(())
391 })
392 .await
393 }
394
395 /// MUST accept pushes to refs/nostr/<event-id>
396 ///
397 /// Spec: GRASP-01, Line 42-44
398 /// > MUST accept pushes via this service to `refs/nostr/<event-id>` but
399 /// > SHOULD reject if event exists on relay listing a different tip
400 async fn test_accepts_nostr_refs(ctx: &TestContext) -> TestResult {
401 TestResult::new(
402 "accepts_nostr_refs",
403 "GRASP-01:42-44",
404 "MUST accept pushes to refs/nostr/<event-id>",
405 )
406 .run(async {
407 let (announcement, _) = ctx.create_repo_with_state().build().await?;
408
409 // Create a PR event
410 let pr_event = ctx.create_pr_event()
411 .for_repo(&announcement)
412 .build()
413 .await?;
414
415 let event_id = pr_event.id();
416
417 // Push to refs/nostr/<event-id>
418 let result = ctx.git_push(
419 &announcement,
420 &format!("refs/nostr/{}", event_id),
421 "commit-sha..."
422 ).await?;
423
424 assert!(
425 result.success,
426 "Push to refs/nostr/<event-id> must succeed"
427 );
428
429 Ok(())
430 })
431 .await
432 }
433
434 /// MUST reject pr/* branches
435 ///
436 /// Spec: GRASP-01, Line 42-44 (implied)
437 /// PRs should use refs/nostr/, not refs/heads/pr/*
438 async fn test_rejects_pr_branches(ctx: &TestContext) -> TestResult {
439 TestResult::new(
440 "rejects_pr_branches",
441 "GRASP-01:42-44",
442 "MUST reject refs/heads/pr/* (use refs/nostr/ instead)",
443 )
444 .run(async {
445 let (announcement, _) = ctx.create_repo_with_state().build().await?;
446
447 // Try to push to pr/* branch
448 let result = ctx.git_push(
449 &announcement,
450 "refs/heads/pr/123",
451 "commit-sha..."
452 ).await;
453
454 assert!(
455 result.is_err() || !result.unwrap().success,
456 "Push to refs/heads/pr/* must be rejected"
457 );
458
459 Ok(())
460 })
461 .await
462 }
463
464 /// MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want
465 ///
466 /// Spec: GRASP-01, Line 48-49
467 /// > MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want`
468 /// > in advertisement and serve available oids.
469 async fn test_allows_tip_sha1_in_want(ctx: &TestContext) -> TestResult {
470 TestResult::new(
471 "allows_tip_sha1_in_want",
472 "GRASP-01:48-49",
473 "MUST advertise and support allow-tip-sha1-in-want",
474 )
475 .run(async {
476 let (announcement, _) = ctx.create_repo_with_state()
477 .branch("main", "a1b2c3d4...")
478 .build()
479 .await?;
480
481 // Fetch git capabilities
482 let caps = ctx.git_capabilities(&announcement).await?;
483
484 assert!(
485 caps.contains("allow-tip-sha1-in-want"),
486 "Git advertisement must include allow-tip-sha1-in-want"
487 );
488
489 assert!(
490 caps.contains("allow-reachable-sha1-in-want"),
491 "Git advertisement must include allow-reachable-sha1-in-want"
492 );
493
494 Ok(())
495 })
496 .await
497 }
498
499 // ================================================================
500 // CORS SUPPORT TESTS
501 // ================================================================
502
503 /// MUST set Access-Control-Allow-Origin: * on ALL responses
504 ///
505 /// Spec: GRASP-01, Line 57
506 /// > 1. Set `Access-Control-Allow-Origin: *` on ALL responses
507 async fn test_cors_allow_origin(ctx: &TestContext) -> TestResult {
508 TestResult::new(
509 "cors_allow_origin",
510 "GRASP-01:57",
511 "MUST set Access-Control-Allow-Origin: * on ALL responses",
512 )
513 .run(async {
514 let paths = vec![
515 "/",
516 "/test-npub/test-repo.git/info/refs?service=git-upload-pack",
517 ];
518
519 for path in paths {
520 let response = ctx.http_get(path).await?;
521
522 assert_eq!(
523 response.headers().get("access-control-allow-origin").unwrap(),
524 "*",
525 "Path {} must have Access-Control-Allow-Origin: *", path
526 );
527 }
528
529 Ok(())
530 })
531 .await
532 }
533
534 /// MUST respond to OPTIONS requests with 204 No Content
535 ///
536 /// Spec: GRASP-01, Line 60
537 /// > 4. Respond to OPTIONS requests with 204 No Content
538 async fn test_cors_options_request(ctx: &TestContext) -> TestResult {
539 TestResult::new(
540 "cors_options_request",
541 "GRASP-01:60",
542 "MUST respond to OPTIONS with 204 No Content",
543 )
544 .run(async {
545 let response = ctx.http_options("/test-npub/test-repo.git/info/refs").await?;
546
547 assert_eq!(
548 response.status(), 204,
549 "OPTIONS request must return 204 No Content"
550 );
551
552 Ok(())
553 })
554 .await
555 }
556}
557```
558
559### Test Result Reporting
560
561```rust
562/// Test result with spec citation
563pub struct TestResult {
564 pub name: String,
565 pub spec_ref: String, // e.g., "GRASP-01:12-13"
566 pub requirement: String, // Exact text from spec
567 pub passed: bool,
568 pub error: Option<String>,
569 pub duration: Duration,
570}
571
572impl TestResult {
573 /// Create a new test result
574 pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self {
575 TestResult {
576 name: name.to_string(),
577 spec_ref: spec_ref.to_string(),
578 requirement: requirement.to_string(),
579 passed: false,
580 error: None,
581 duration: Duration::default(),
582 }
583 }
584
585 /// Run the test
586 pub async fn run<F, Fut>(mut self, test_fn: F) -> Self
587 where
588 F: FnOnce() -> Fut,
589 Fut: Future<Output = Result<(), String>>,
590 {
591 let start = Instant::now();
592
593 match test_fn().await {
594 Ok(()) => {
595 self.passed = true;
596 }
597 Err(e) => {
598 self.passed = false;
599 self.error = Some(e);
600 }
601 }
602
603 self.duration = start.elapsed();
604 self
605 }
606}
607
608/// Collection of test results for a spec
609pub struct ComplianceResult {
610 pub spec: String,
611 pub results: Vec<TestResult>,
612}
613
614impl ComplianceResult {
615 pub fn report(&self) -> String {
616 let mut output = String::new();
617
618 output.push_str(&format!("\n{} Compliance Report\n", self.spec));
619 output.push_str(&"=".repeat(60));
620 output.push_str("\n\n");
621
622 let passed = self.results.iter().filter(|r| r.passed).count();
623 let total = self.results.len();
624
625 output.push_str(&format!("Results: {}/{} passed\n\n", passed, total));
626
627 for result in &self.results {
628 let status = if result.passed { "✓" } else { "✗" };
629
630 output.push_str(&format!(
631 "{} {} ({})\n",
632 status, result.name, result.spec_ref
633 ));
634
635 output.push_str(&format!(" Requirement: {}\n", result.requirement));
636
637 if let Some(error) = &result.error {
638 output.push_str(&format!(" Error: {}\n", error));
639 }
640
641 output.push_str(&format!(" Duration: {:?}\n\n", result.duration));
642 }
643
644 output
645 }
646}
647```
648
649### Usage Example
650
651```rust
652// examples/test_implementation.rs
653
654use grasp_compliance_tests::{TestContext, Grasp01Spec};
655
656#[tokio::main]
657async fn main() {
658 // Configure the implementation to test
659 let ctx = TestContext::builder()
660 .base_url("http://localhost:8080")
661 .websocket_url("ws://localhost:8080")
662 .domain("localhost:8080")
663 .build();
664
665 // Run GRASP-01 compliance tests
666 let results = Grasp01Spec::test_compliance(&ctx).await;
667
668 // Print report
669 println!("{}", results.report());
670
671 // Exit with error if any tests failed
672 if !results.all_passed() {
673 std::process::exit(1);
674 }
675}
676```
677
678### Integration with ngit-grasp
679
680In `ngit-grasp/tests/compliance.rs`:
681
682```rust
683use grasp_compliance_tests::{TestContext, Grasp01Spec};
684
685#[tokio::test]
686async fn test_grasp_01_compliance() {
687 // Start test server
688 let server = start_test_server().await;
689
690 // Configure test context
691 let ctx = TestContext::builder()
692 .base_url(&server.url())
693 .websocket_url(&server.ws_url())
694 .domain(&server.domain())
695 .build();
696
697 // Run compliance tests
698 let results = Grasp01Spec::test_compliance(&ctx).await;
699
700 // Assert all tests passed
701 assert!(
702 results.all_passed(),
703 "GRASP-01 compliance failed:\n{}",
704 results.report()
705 );
706}
707```
708
709## Unit Testing Strategy
710
711### Git Module Tests
712
713```rust
714// src/git/parser.rs tests
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 #[test]
721 fn test_parse_pkt_line() {
722 let data = b"0006a\n";
723 let (length, payload) = parse_pkt_line(data).unwrap();
724 assert_eq!(length, 6);
725 assert_eq!(payload, b"a\n");
726 }
727
728 #[test]
729 fn test_parse_flush_packet() {
730 let data = b"0000";
731 let result = parse_pkt_line(data).unwrap();
732 assert_eq!(result.0, 0);
733 }
734
735 #[test]
736 fn test_parse_ref_updates() {
737 let body = b"00820000000000000000000000000000000000000000 \
738 a1b2c3d4e5f6789012345678901234567890abcd \
739 refs/heads/main\0 report-status\n\
740 0000";
741
742 let updates = parse_ref_updates(body).unwrap();
743 assert_eq!(updates.len(), 1);
744 assert_eq!(updates[0].ref_name, "refs/heads/main");
745 }
746}
747```
748
749### Authorization Module Tests
750
751```rust
752// src/git/authorization.rs tests
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 #[test]
759 fn test_get_maintainers_single() {
760 let events = vec![
761 create_test_announcement("alice", "repo1", vec![]),
762 ];
763
764 let maintainers = get_maintainers(&events, "alice", "repo1");
765 assert_eq!(maintainers, vec!["alice"]);
766 }
767
768 #[test]
769 fn test_get_maintainers_recursive() {
770 let events = vec![
771 create_test_announcement("alice", "repo1", vec!["bob"]),
772 create_test_announcement("bob", "repo1", vec![]),
773 ];
774
775 let maintainers = get_maintainers(&events, "alice", "repo1");
776 assert!(maintainers.contains(&"alice".to_string()));
777 assert!(maintainers.contains(&"bob".to_string()));
778 }
779
780 #[test]
781 fn test_get_maintainers_circular() {
782 let events = vec![
783 create_test_announcement("alice", "repo1", vec!["bob"]),
784 create_test_announcement("bob", "repo1", vec!["alice"]),
785 ];
786
787 let maintainers = get_maintainers(&events, "alice", "repo1");
788 assert_eq!(maintainers.len(), 2);
789 }
790
791 #[test]
792 fn test_validate_state_ref_matching() {
793 let state = RepositoryState {
794 branches: HashMap::from([
795 ("main".into(), "a1b2c3d4...".into()),
796 ]),
797 tags: HashMap::new(),
798 };
799
800 let update = RefUpdate {
801 old_oid: "0000...".into(),
802 new_oid: "a1b2c3d4...".into(),
803 ref_name: "refs/heads/main".into(),
804 };
805
806 assert!(validate_state_ref(&state, &update).is_ok());
807 }
808
809 #[test]
810 fn test_validate_state_ref_mismatch() {
811 let state = RepositoryState {
812 branches: HashMap::from([
813 ("main".into(), "aaaa1111...".into()),
814 ]),
815 tags: HashMap::new(),
816 };
817
818 let update = RefUpdate {
819 old_oid: "0000...".into(),
820 new_oid: "bbbb2222...".into(),
821 ref_name: "refs/heads/main".into(),
822 };
823
824 assert!(validate_state_ref(&state, &update).is_err());
825 }
826}
827```
828
829## Integration Testing Strategy
830
831### Repository Lifecycle Tests
832
833```rust
834// tests/integration/repository_lifecycle.rs
835
836#[tokio::test]
837async fn test_repository_creation_on_announcement() {
838 let app = test_app().await;
839
840 // Send repository announcement
841 let announcement = create_announcement()
842 .with_identifier("test-repo")
843 .with_clone_tag(app.domain())
844 .with_relay_tag(app.domain())
845 .sign()
846 .await;
847
848 app.send_event(announcement).await.unwrap();
849
850 // Wait for async processing
851 tokio::time::sleep(Duration::from_secs(1)).await;
852
853 // Verify repository was created
854 let repo_path = app.git_data_path()
855 .join(announcement.author_npub())
856 .join("test-repo.git");
857
858 assert!(repo_path.exists());
859 assert!(repo_path.join("HEAD").exists());
860 assert!(repo_path.join("config").exists());
861}
862
863#[tokio::test]
864async fn test_push_validation_flow() {
865 let app = test_app().await;
866
867 // Create repository with state
868 let (announcement, state) = app.create_repo_with_state()
869 .branch("main", "commit-sha-123")
870 .build()
871 .await;
872
873 // Attempt push matching state
874 let result = app.git_push("main", "commit-sha-123").await;
875 assert!(result.success);
876
877 // Attempt push NOT matching state
878 let result = app.git_push("main", "different-sha-456").await;
879 assert!(!result.success);
880 assert!(result.stderr.contains("state event"));
881}
882```
883
884### Multi-Maintainer Tests
885
886```rust
887#[tokio::test]
888async fn test_multi_maintainer_push() {
889 let app = test_app().await;
890
891 // Alice creates repo, lists Bob as maintainer
892 let alice_announcement = create_announcement()
893 .author("alice")
894 .maintainers(vec!["bob"])
895 .build();
896
897 app.send_event(alice_announcement).await.unwrap();
898
899 // Bob creates state event
900 let bob_state = create_state()
901 .author("bob")
902 .branch("main", "commit-123")
903 .build();
904
905 app.send_event(bob_state).await.unwrap();
906
907 // Bob's push should succeed
908 let result = app.git_push_as("bob", "main", "commit-123").await;
909 assert!(result.success);
910}
911```
912
913## End-to-End Testing
914
915### Real Git Client Tests
916
917```rust
918// tests/e2e/git_client.rs
919
920#[tokio::test]
921async fn test_real_git_clone() {
922 let app = test_app().await;
923
924 // Setup repository
925 let (announcement, _) = app.create_repo_with_commits()
926 .commit("Initial commit", "file.txt", "content")
927 .build()
928 .await;
929
930 // Clone with real git client
931 let temp_dir = TempDir::new().unwrap();
932 let clone_url = format!(
933 "http://{}/{}/{}.git",
934 app.domain(),
935 announcement.author_npub(),
936 announcement.identifier()
937 );
938
939 let output = Command::new("git")
940 .args(&["clone", &clone_url])
941 .current_dir(&temp_dir)
942 .output()
943 .await
944 .unwrap();
945
946 assert!(output.status.success());
947 assert!(temp_dir.path().join(announcement.identifier()).exists());
948}
949
950#[tokio::test]
951async fn test_real_git_push() {
952 let app = test_app().await;
953
954 // Create repository
955 let (announcement, keys) = app.create_repo().await;
956
957 // Clone it
958 let temp_dir = TempDir::new().unwrap();
959 git_clone(&app, &announcement, &temp_dir).await;
960
961 // Make changes
962 let repo_dir = temp_dir.path().join(announcement.identifier());
963 tokio::fs::write(repo_dir.join("new-file.txt"), "content").await.unwrap();
964
965 // Commit
966 git_commit(&repo_dir, "Add new file").await;
967
968 // Send state event for new commit
969 let new_commit = git_rev_parse(&repo_dir, "HEAD").await;
970 app.send_state(&announcement, "main", &new_commit, &keys).await;
971
972 // Push
973 let output = Command::new("git")
974 .args(&["push", "origin", "main"])
975 .current_dir(&repo_dir)
976 .output()
977 .await
978 .unwrap();
979
980 assert!(output.status.success());
981}
982```
983
984## Performance Testing
985
986### Load Tests
987
988```rust
989// tests/performance/load.rs
990
991#[tokio::test]
992async fn test_concurrent_pushes() {
993 let app = test_app().await;
994
995 let num_concurrent = 100;
996 let mut handles = vec![];
997
998 for i in 0..num_concurrent {
999 let app = app.clone();
1000 let handle = tokio::spawn(async move {
1001 let (announcement, state) = app.create_repo_with_state()
1002 .branch("main", &format!("commit-{}", i))
1003 .build()
1004 .await;
1005
1006 app.git_push("main", &format!("commit-{}", i)).await
1007 });
1008 handles.push(handle);
1009 }
1010
1011 let results = futures::future::join_all(handles).await;
1012
1013 // All should succeed
1014 for result in results {
1015 assert!(result.unwrap().success);
1016 }
1017}
1018
1019#[tokio::test]
1020async fn test_event_ingestion_throughput() {
1021 let app = test_app().await;
1022
1023 let num_events = 1000;
1024 let start = Instant::now();
1025
1026 for i in 0..num_events {
1027 let event = create_announcement()
1028 .with_identifier(&format!("repo-{}", i))
1029 .build();
1030 app.send_event(event).await.unwrap();
1031 }
1032
1033 let duration = start.elapsed();
1034 let throughput = num_events as f64 / duration.as_secs_f64();
1035
1036 println!("Event throughput: {:.2} events/sec", throughput);
1037 assert!(throughput > 100.0, "Throughput too low");
1038}
1039```
1040
1041## Test Utilities
1042
1043### Test Fixtures
1044
1045```rust
1046// tests/common/fixtures.rs
1047
1048pub struct TestEventBuilder {
1049 kind: Kind,
1050 content: String,
1051 tags: Vec<Tag>,
1052 keys: Option<Keys>,
1053}
1054
1055impl TestEventBuilder {
1056 pub fn announcement() -> Self {
1057 TestEventBuilder {
1058 kind: Kind::RepositoryAnnouncement,
1059 content: String::new(),
1060 tags: vec![],
1061 keys: None,
1062 }
1063 }
1064
1065 pub fn with_identifier(mut self, id: &str) -> Self {
1066 self.tags.push(Tag::Identifier(id.to_string()));
1067 self
1068 }
1069
1070 pub fn with_clone_tag(mut self, url: &str) -> Self {
1071 self.tags.push(Tag::new("clone", vec![url]));
1072 self
1073 }
1074
1075 pub async fn build(self) -> Event {
1076 let keys = self.keys.unwrap_or_else(|| Keys::generate());
1077 EventBuilder::new(self.kind, self.content, self.tags)
1078 .to_event(&keys)
1079 .await
1080 .unwrap()
1081 }
1082}
1083```
1084
1085### Test Server
1086
1087```rust
1088// tests/common/server.rs
1089
1090pub struct TestServer {
1091 addr: SocketAddr,
1092 handle: JoinHandle<()>,
1093}
1094
1095impl TestServer {
1096 pub async fn start() -> Self {
1097 let config = Config {
1098 domain: "localhost:0".to_string(),
1099 git_data_path: TempDir::new().unwrap().into_path(),
1100 relay_data_path: TempDir::new().unwrap().into_path(),
1101 // ... other config
1102 };
1103
1104 let app = create_app(config).await;
1105 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1106 let addr = listener.local_addr().unwrap();
1107
1108 let handle = tokio::spawn(async move {
1109 axum::serve(listener, app).await.unwrap();
1110 });
1111
1112 // Wait for server to be ready
1113 tokio::time::sleep(Duration::from_millis(100)).await;
1114
1115 TestServer { addr, handle }
1116 }
1117
1118 pub fn url(&self) -> String {
1119 format!("http://{}", self.addr)
1120 }
1121
1122 pub fn ws_url(&self) -> String {
1123 format!("ws://{}", self.addr)
1124 }
1125}
1126```
1127
1128## CI/CD Integration
1129
1130### GitHub Actions Workflow
1131
1132```yaml
1133# .github/workflows/test.yml
1134
1135name: Test
1136
1137on: [push, pull_request]
1138
1139jobs:
1140 unit-tests:
1141 runs-on: ubuntu-latest
1142 steps:
1143 - uses: actions/checkout@v3
1144 - uses: actions-rs/toolchain@v1
1145 with:
1146 toolchain: stable
1147 - name: Run unit tests
1148 run: cargo test --lib
1149
1150 integration-tests:
1151 runs-on: ubuntu-latest
1152 steps:
1153 - uses: actions/checkout@v3
1154 - uses: actions-rs/toolchain@v1
1155 with:
1156 toolchain: stable
1157 - name: Install Git
1158 run: sudo apt-get install -y git
1159 - name: Run integration tests
1160 run: cargo test --test '*'
1161
1162 compliance-tests:
1163 runs-on: ubuntu-latest
1164 steps:
1165 - uses: actions/checkout@v3
1166 - uses: actions-rs/toolchain@v1
1167 with:
1168 toolchain: stable
1169 - name: Run GRASP-01 compliance tests
1170 run: cargo test --test compliance
1171 - name: Generate compliance report
1172 run: cargo run --example compliance-report > compliance-report.txt
1173 - name: Upload compliance report
1174 uses: actions/upload-artifact@v3
1175 with:
1176 name: compliance-report
1177 path: compliance-report.txt
1178```
1179
1180## Test Coverage
1181
1182### Target Coverage
1183
1184- **Unit Tests**: >80% line coverage
1185- **Integration Tests**: All critical paths
1186- **Compliance Tests**: 100% of GRASP-01 requirements
1187- **E2E Tests**: Key user workflows
1188
1189### Measuring Coverage
1190
1191```bash
1192# Install tarpaulin
1193cargo install cargo-tarpaulin
1194
1195# Run with coverage
1196cargo tarpaulin --out Html --output-dir coverage
1197
1198# View report
1199open coverage/index.html
1200```
1201
1202## Documentation Testing
1203
1204### Doc Tests
1205
1206```rust
1207/// Parse a pkt-line from Git protocol
1208///
1209/// # Examples
1210///
1211/// ```
1212/// use ngit_grasp::git::parse_pkt_line;
1213///
1214/// let data = b"0006a\n";
1215/// let (length, payload) = parse_pkt_line(data).unwrap();
1216/// assert_eq!(length, 6);
1217/// assert_eq!(payload, b"a\n");
1218/// ```
1219pub fn parse_pkt_line(data: &[u8]) -> Result<(usize, &[u8])> {
1220 // implementation
1221}
1222```
1223
1224## Summary
1225
1226This comprehensive test strategy ensures:
1227
12281. **Spec Compliance**: Every GRASP requirement has a corresponding test
12292. **Reusability**: Compliance tests can validate any GRASP implementation
12303. **Clear Failures**: Test failures cite exact spec lines
12314. **Comprehensive Coverage**: Unit, integration, compliance, and E2E tests
12325. **Maintainability**: Tests mirror spec structure for easy updates
1233
1234The compliance testing tool is a standalone crate that can be:
1235- Used by ngit-grasp for self-validation
1236- Published for other GRASP implementations to use
1237- Updated as new GRASP specs are released (GRASP-02, GRASP-05)
1238- Run in CI/CD for continuous compliance verification