upleb.uk

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

summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/explanation/architecture.md716
-rw-r--r--docs/explanation/inline-authorization.md126
-rw-r--r--docs/learnings/grasp-audit.md209
-rw-r--r--docs/reference/test-strategy.md1339
4 files changed, 509 insertions, 1881 deletions
diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md
index ebf7a74..3fb895c 100644
--- a/docs/explanation/architecture.md
+++ b/docs/explanation/architecture.md
@@ -2,16 +2,16 @@
2 2
3## Executive Summary 3## Executive Summary
4 4
5`ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that the `git-http-backend` Rust crate provides sufficient flexibility to intercept and validate Git push operations before they reach the Git repository, eliminating the need for pre-receive hooks. 5`ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that we can intercept and validate Git push operations at the HTTP handler level before reaching the Git repository, eliminating the need for pre-receive hooks.
6 6
7## Architectural Decision: Inline vs. Hook-Based Authorization 7## Architectural Decision: Inline vs. Hook-Based Authorization
8 8
9### Investigation Summary 9### Investigation Summary
10 10
11After examining both the reference implementation and the `git-http-backend` Rust crate, we have two options: 11After examining both the reference implementation and HTTP server options, we have two options:
12 12
13#### Option 1: Hook-Based (Reference Implementation Approach) 13#### Option 1: Hook-Based (Reference Implementation Approach)
14- Use `git-http-backend` crate as-is 14- Use standard Git HTTP backend
15- Create pre-receive and post-receive hooks 15- Create pre-receive and post-receive hooks
16- Hooks query the Nostr relay and validate pushes 16- Hooks query the Nostr relay and validate pushes
17- **Pros**: Follows reference implementation closely 17- **Pros**: Follows reference implementation closely
@@ -28,7 +28,7 @@ After examining both the reference implementation and the `git-http-backend` Rus
28 28
29**Rationale:** 29**Rationale:**
30 30
311. **The `git-http-backend` crate is sufficiently flexible**: Examining `src/actix/git_receive_pack.rs` shows it spawns `git receive-pack` as a subprocess and streams data. We can intercept this. 311. **Full control over HTTP layer**: Using Hyper directly gives us complete control over request handling, WebSocket upgrades, and CORS headers.
32 32
332. **Better Developer Experience**: 332. **Better Developer Experience**:
34 - Validation errors can be returned as proper HTTP responses 34 - Validation errors can be returned as proper HTTP responses
@@ -56,7 +56,7 @@ After examining both the reference implementation and the `git-http-backend` Rus
56│ │ 56│ │
57│ ┌──────────────────┐ ┌──────────────────┐ │ 57│ ┌──────────────────┐ ┌──────────────────┐ │
58│ │ HTTP Router │ │ Nostr Relay │ │ 58│ │ HTTP Router │ │ Nostr Relay │ │
59│ │ (actix-web) │ │ (nostr-relay- │ │ 59│ │ (Hyper) │ │ (nostr-relay- │ │
60│ │ │ │ builder) │ │ 60│ │ │ │ builder) │ │
61│ └────────┬─────────┘ └────────┬─────────┘ │ 61│ └────────┬─────────┘ └────────┬─────────┘ │
62│ │ │ │ 62│ │ │ │
@@ -90,299 +90,149 @@ After examining both the reference implementation and the `git-http-backend` Rus
90 90
91## Component Design 91## Component Design
92 92
93### 1. Main Server (`src/main.rs`) 93### 1. Main Server ([`src/main.rs`](src/main.rs))
94 94
95**Responsibilities:** 95**Responsibilities:**
96- Initialize configuration from environment 96- Initialize configuration from environment (clap + dotenvy)
97- Set up actix-web HTTP server 97- Set up Hyper HTTP server with request routing
98- Initialize Nostr relay builder 98- Initialize Nostr relay builder with custom [`Nip34WritePolicy`](src/nostr/builder.rs:51)
99- Set up shared storage 99- Set up shared storage (LMDB, NostrDB, or Memory)
100- Configure routes for both Git and Nostr endpoints 100- Handle WebSocket upgrades for Nostr relay
101- Handle graceful shutdown 101- Handle graceful shutdown
102 102
103**Key Dependencies:** 103**Key Dependencies:**
104```rust 104```rust
105actix-web = "4" 105hyper = "1"
106tokio = { version = "1", features = ["full"] } 106tokio = { version = "1", features = ["full"] }
107nostr-relay-builder = "0.43" 107nostr-relay-builder = "0.43"
108nostr-sdk = "0.43" 108nostr-sdk = "0.43"
109nostr-lmdb = "0.43"
109``` 110```
110 111
111### 2. Git Module (`src/git/`) 112### 2. HTTP Module ([`src/http/mod.rs`](src/http/mod.rs))
112 113
113#### `handler.rs` - Git HTTP Handlers 114**Responsibilities:**
115- Route HTTP requests to appropriate handlers
116- WebSocket upgrade for Nostr relay at `/`
117- Git Smart HTTP endpoints at `/<npub>/<identifier>.git/*`
118- Landing pages and NIP-11 document serving
119- CORS headers on all responses (GRASP-01 requirement)
114 120
115Implements actix-web handlers for Git Smart HTTP protocol: 121**Key Implementation Details:**
116 122
117```rust 123```rust
118// GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack 124// CORS headers required by GRASP-01 specification
119async fn info_refs_upload_pack( 125const CORS_ALLOW_ORIGIN: &str = "*";
120 req: HttpRequest, 126const CORS_ALLOW_METHODS: &str = "GET, POST";
121 state: web::Data<AppState>, 127const CORS_ALLOW_HEADERS: &str = "Content-Type";
122) -> Result<HttpResponse> 128
123 129/// Add CORS headers to a response builder
124// POST /<npub>/<identifier>.git/git-upload-pack 130fn add_cors_headers(builder: http::response::Builder) -> http::response::Builder {
125async fn git_upload_pack( 131 builder
126 req: HttpRequest, 132 .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN)
127 body: web::Payload, 133 .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS)
128 state: web::Data<AppState>, 134 .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS)
129) -> Result<HttpResponse> 135}
130
131// GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack
132async fn info_refs_receive_pack(
133 req: HttpRequest,
134 state: web::Data<AppState>,
135) -> Result<HttpResponse>
136
137// POST /<npub>/<identifier>.git/git-receive-pack
138// THIS IS WHERE THE MAGIC HAPPENS
139async fn git_receive_pack(
140 req: HttpRequest,
141 body: web::Payload,
142 state: web::Data<AppState>,
143) -> Result<HttpResponse>
144``` 136```
145 137
146#### `authorization.rs` - Push Validation 138See [`src/http/mod.rs:29-84`](src/http/mod.rs:29-84) for the full CORS implementation.
147 139
148**Core Logic:** 140### 3. Git Module ([`src/git/`](src/git/))
149 141
150```rust 142#### [`handlers.rs`](src/git/handlers.rs) - Git HTTP Handlers
151pub struct PushValidator {
152 nostr_client: Arc<Client>,
153 relay_url: String,
154}
155 143
156impl PushValidator { 144Implements handlers for Git Smart HTTP protocol:
157 /// Validate a push operation against Nostr state
158 pub async fn validate_push(
159 &self,
160 npub: &str,
161 identifier: &str,
162 ref_updates: Vec<RefUpdate>,
163 ) -> Result<ValidationResult> {
164 // 1. Fetch announcement and state events from local relay
165 let events = self.fetch_events(identifier).await?;
166
167 // 2. Extract pubkey from npub
168 let pubkey = decode_npub(npub)?;
169
170 // 3. Get recursive maintainer set
171 let maintainers = get_maintainers(&events, &pubkey, identifier);
172
173 // 4. Get latest state from maintainers
174 let state = get_state_from_maintainers(&events, &maintainers)?;
175
176 // 5. Validate each ref update
177 for ref_update in ref_updates {
178 if ref_update.ref_name.starts_with("refs/nostr/") {
179 // Allow refs/nostr/<event-id> for PRs
180 validate_pr_ref(&ref_update)?;
181 } else if ref_update.ref_name.starts_with("refs/heads/pr/") {
182 // Reject pr/* branches - should use refs/nostr/
183 return Err(Error::InvalidRef("pr/* branches must use refs/nostr/"));
184 } else {
185 // Validate against state event
186 validate_state_ref(&state, &ref_update)?;
187 }
188 }
189
190 Ok(ValidationResult::Accept)
191 }
192}
193```
194
195**Key Functions:**
196 145
197```rust 146```rust
198/// Parse ref updates from git-receive-pack request body 147/// Handle GET /info/refs?service=git-{upload,receive}-pack
199fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> 148pub async fn handle_info_refs(
200 149 repo_path: PathBuf,
201/// Recursively find all maintainers 150 service: GitService,
202fn get_maintainers( 151) -> Result<Response<Full<Bytes>>, GitError>
203 events: &[Event], 152
204 pubkey: &str, 153/// Handle POST /git-upload-pack (clone/fetch)
154pub async fn handle_upload_pack(
155 repo_path: PathBuf,
156 body: Bytes,
157) -> Result<Response<Full<Bytes>>, GitError>
158
159/// Handle POST /git-receive-pack (push)
160/// THIS IS WHERE THE MAGIC HAPPENS - validates against state before accepting
161pub async fn handle_receive_pack(
162 repo_path: PathBuf,
163 body: Bytes,
164 database: SharedDatabase,
165 npub: &str,
205 identifier: &str, 166 identifier: &str,
206) -> Vec<String> 167) -> Result<Response<Full<Bytes>>, GitError>
207
208/// Get latest state from maintainer set
209fn get_state_from_maintainers(
210 events: &[Event],
211 maintainers: &[String],
212) -> Result<RepositoryState>
213
214/// Validate a ref matches the state event
215fn validate_state_ref(
216 state: &RepositoryState,
217 ref_update: &RefUpdate,
218) -> Result<()>
219``` 168```
220 169
221### 3. Nostr Module (`src/nostr/`) 170See [`src/git/handlers.rs:22-98`](src/git/handlers.rs:22-98) for the info-refs implementation.
222 171
223#### `relay.rs` - Relay Configuration 172#### [`authorization.rs`](src/git/authorization.rs) - Push Validation
224 173
225```rust 174**Core Logic:**
226pub async fn build_relay(config: &Config) -> Result<LocalRelay> {
227 let builder = RelayBuilder::default()
228 .write_policy(RepositoryAnnouncementPolicy::new(config.domain.clone()))
229 .write_policy(RelatedEventsPolicy::new())
230 .query_policy(StandardQueryPolicy::new())
231 .on_event_saved(create_repository_hook(config.git_data_path.clone()));
232
233 // Configure storage backend (LMDB or NDB)
234 let relay = LocalRelay::run(builder).await?;
235
236 Ok(relay)
237}
238```
239
240#### `events.rs` - Event Handlers
241 175
242```rust 176```rust
243/// Hook called when events are saved 177/// Get authorization info for a repository owner
244pub fn create_repository_hook( 178pub async fn get_authorization_for_owner(
245 git_data_path: PathBuf, 179 database: &SharedDatabase,
246) -> impl Fn(&Event) -> BoxFuture<'static, ()> { 180 pubkey: &PublicKey,
247 move |event: &Event| { 181 identifier: &str,
248 let git_path = git_data_path.clone(); 182) -> Result<AuthorizationResult, AuthorizationError>
249 Box::pin(async move {
250 if event.kind == Kind::RepositoryAnnouncement {
251 handle_repository_announcement(event, &git_path).await;
252 } else if event.kind == Kind::RepositoryState {
253 handle_repository_state(event, &git_path).await;
254 }
255 })
256 }
257}
258 183
259async fn handle_repository_announcement(event: &Event, git_path: &Path) { 184/// Validate that pushed refs match the authorized state
260 // 1. Parse repository from event 185pub fn validate_push_refs(
261 // 2. Check if listed in clone and relays tags 186 pushed_refs: &[PushedRef],
262 // 3. Create empty bare Git repository 187 state: &RepositoryState,
263 // 4. Configure uploadpack.allowTipSHA1InWant 188) -> Result<(), AuthorizationError>
264 // 5. Configure uploadpack.allowUnreachable
265 // 6. Configure http.receivepack
266}
267 189
268async fn handle_repository_state(event: &Event, git_path: &Path) { 190/// Validate refs/nostr/<event-id> pushes
269 // 1. Parse state from event 191pub fn validate_nostr_ref_pushes(
270 // 2. Update repository HEAD if needed 192 pushed_refs: &[PushedRef],
271 // 3. Trigger proactive sync (GRASP-02) 193 database: &SharedDatabase,
272} 194) -> Result<(), AuthorizationError>
273``` 195```
274 196
275**Write Policies:** 197### 4. Nostr Module ([`src/nostr/`](src/nostr/))
276 198
277```rust 199#### [`builder.rs`](src/nostr/builder.rs) - Relay Configuration
278/// Accept repository announcements that list this instance
279pub struct RepositoryAnnouncementPolicy {
280 domain: String,
281}
282
283impl WritePolicy for RepositoryAnnouncementPolicy {
284 fn admit_event(&self, event: &Event, _addr: &SocketAddr)
285 -> BoxFuture<PolicyResult>
286 {
287 Box::pin(async move {
288 if event.kind != Kind::RepositoryAnnouncement {
289 return PolicyResult::Accept; // Not our concern
290 }
291
292 // Check if this instance is in clone and relays tags
293 let has_clone = event.tags.iter()
294 .any(|t| t.kind() == "clone" && t.content() == Some(&self.domain));
295 let has_relay = event.tags.iter()
296 .any(|t| t.kind() == "relays" && t.content() == Some(&self.domain));
297
298 if has_clone && has_relay {
299 PolicyResult::Accept
300 } else {
301 PolicyResult::Reject("instance not listed in clone and relays".into())
302 }
303 })
304 }
305}
306 200
307/// Accept events related to stored announcements/issues/patches 201The [`Nip34WritePolicy`](src/nostr/builder.rs:51) is the core event validation logic:
308pub struct RelatedEventsPolicy;
309 202
310impl WritePolicy for RelatedEventsPolicy { 203```rust
311 fn admit_event(&self, event: &Event, _addr: &SocketAddr) 204/// NIP-34 Write Policy with Full GRASP-01 Event Validation
312 -> BoxFuture<PolicyResult> 205///
313 { 206/// Validates all events according to GRASP-01 specification:
314 // Accept if event tags or is tagged by stored events 207/// - Repository announcements must list service in clone and relays tags
315 // Implementation requires querying the event store 208/// EXCEPTION: Recursive maintainer announcements are accepted even without
316 } 209/// listing the service, to enable maintainer chain discovery and GRASP-02 sync
210/// - Repository state announcements must have valid structure
211/// - Other events must reference accepted repositories or events
212/// - Forward references are supported (events referenced by accepted events)
213/// - Orphan events with no valid references are rejected
214pub struct Nip34WritePolicy {
215 domain: String,
216 database: SharedDatabase,
217 git_data_path: PathBuf,
317} 218}
318``` 219```
319 220
320### 4. Storage Module (`src/storage/`) 221See [`src/nostr/builder.rs:38-78`](src/nostr/builder.rs:38-78) for the full policy struct.
321 222
322#### `repository.rs` - Repository Management 223#### [`events.rs`](src/nostr/events.rs) - Event Parsing
224
225Provides structures for parsing NIP-34 events:
323 226
324```rust 227```rust
325pub struct RepositoryManager { 228/// Parsed repository announcement (Kind 30617)
326 git_data_path: PathBuf, 229pub struct RepositoryAnnouncement { ... }
327}
328 230
329impl RepositoryManager { 231/// Parsed repository state (Kind 30618)
330 /// Create a new bare Git repository 232pub struct RepositoryState { ... }
331 pub async fn create_repository(
332 &self,
333 npub: &str,
334 identifier: &str,
335 ) -> Result<PathBuf> {
336 let repo_path = self.git_data_path
337 .join(npub)
338 .join(format!("{}.git", identifier));
339
340 // Create directory
341 tokio::fs::create_dir_all(&repo_path).await?;
342
343 // Initialize bare repo
344 Command::new("git")
345 .args(&["init", "--bare"])
346 .arg(&repo_path)
347 .output()
348 .await?;
349
350 // Configure
351 self.configure_repository(&repo_path).await?;
352
353 Ok(repo_path)
354 }
355
356 async fn configure_repository(&self, repo_path: &Path) -> Result<()> {
357 // Enable unauthenticated push (we handle auth ourselves)
358 git_config(repo_path, "http.receivepack", "true").await?;
359
360 // Enable tip SHA1 fetching (required for ngit)
361 git_config(repo_path, "uploadpack.allowTipSHA1InWant", "true").await?;
362
363 // Enable unreachable object fetching
364 git_config(repo_path, "uploadpack.allowUnreachable", "true").await?;
365
366 Ok(())
367 }
368
369 /// Check if repository exists
370 pub async fn repository_exists(
371 &self,
372 npub: &str,
373 identifier: &str,
374 ) -> bool {
375 let repo_path = self.git_data_path
376 .join(npub)
377 .join(format!("{}.git", identifier));
378
379 repo_path.join("HEAD").exists() &&
380 repo_path.join("config").exists()
381 }
382}
383``` 233```
384 234
385### 5. Configuration (`src/config.rs`) 235### 5. Configuration ([`src/config.rs`](src/config.rs))
386 236
387```rust 237```rust
388pub struct Config { 238pub struct Config {
@@ -393,34 +243,18 @@ pub struct Config {
393 pub git_data_path: PathBuf, 243 pub git_data_path: PathBuf,
394 pub relay_data_path: PathBuf, 244 pub relay_data_path: PathBuf,
395 pub bind_address: SocketAddr, 245 pub bind_address: SocketAddr,
396 pub log_level: String, 246 pub database_backend: DatabaseBackend,
397} 247}
398 248
399impl Config { 249pub enum DatabaseBackend {
400 pub fn from_env() -> Result<Self> { 250 Lmdb, // Default, production use
401 Ok(Config { 251 NostrDb, // Alternative
402 domain: env::var("NGIT_DOMAIN")?, 252 Memory, // Testing
403 owner_npub: env::var("NGIT_OWNER_NPUB")?,
404 relay_name: env::var("NGIT_RELAY_NAME")?,
405 relay_description: env::var("NGIT_RELAY_DESCRIPTION")?,
406 git_data_path: PathBuf::from(
407 env::var("NGIT_GIT_DATA_PATH")
408 .unwrap_or_else(|_| "./data/git".to_string())
409 ),
410 relay_data_path: PathBuf::from(
411 env::var("NGIT_RELAY_DATA_PATH")
412 .unwrap_or_else(|_| "./data/relay".to_string())
413 ),
414 bind_address: env::var("NGIT_BIND_ADDRESS")
415 .unwrap_or_else(|_| "127.0.0.1:8080".to_string())
416 .parse()?,
417 log_level: env::var("RUST_LOG")
418 .unwrap_or_else(|_| "info".to_string()),
419 })
420 }
421} 253}
422``` 254```
423 255
256Configuration is loaded via **clap CLI > environment variables > .env > defaults**.
257
424## Data Flow 258## Data Flow
425 259
426### Push Operation Flow 260### Push Operation Flow
@@ -428,327 +262,120 @@ impl Config {
428``` 262```
4291. Git Client → POST /<npub>/<id>.git/git-receive-pack 2631. Git Client → POST /<npub>/<id>.git/git-receive-pack
430 264
4312. git_receive_pack handler receives request 2652. HttpService routes to git::handlers::handle_receive_pack()
432 266
4333. Parse ref updates from request body 2673. Parse ref updates from request body (pkt-line format)
434 268
4354. Extract npub and identifier from URL 2694. Extract npub and identifier from URL
436 270
4375. PushValidator::validate_push() 2715. authorization::get_authorization_for_owner()
438 ├─ Fetch events from local Nostr relay 272 ├─ Query database for announcements
439 ├─ Get maintainers recursively 273 ├─ Build recursive maintainer set
440 ├─ Get latest state from maintainers 274 └─ Get latest authorized state
441 └─ Validate each ref update 275
2766. authorization::validate_push_refs()
277 ├─ Check each ref matches state
278 └─ Validate refs/nostr/ pushes
442 279
4436. If VALID: 2807. If VALID:
444 ├─ Spawn git-receive-pack subprocess 281 ├─ Spawn git-receive-pack subprocess
445 ├─ Stream request body to git stdin 282 ├─ Stream request body to git stdin
446 └─ Stream git stdout back to client 283 └─ Stream git stdout back to client
447 284
4487. If INVALID: 2858. If INVALID:
449 └─ Return HTTP 403 with error message 286 └─ Return HTTP 403 with error message
450``` 287```
451 288
452### Repository Announcement Flow 289### Repository Announcement Flow
453 290
454``` 291```
4551. Nostr Client → EVENT (Kind 30317) 2921. Nostr Client → EVENT (Kind 30617)
456 293
4572. Nostr relay receives event 2942. Nostr relay receives event
458 295
4593. RepositoryAnnouncementPolicy::admit_event() 2963. Nip34WritePolicy::admit_event()
460 ├─ Check if instance in clone tags 297 ├─ Check if instance in clone tags
461 ├─ Check if instance in relays tags 298 ├─ Check if instance in relays tags
299 ├─ OR: Check if recursive maintainer
462 └─ Accept or reject 300 └─ Accept or reject
463 301
4644. If ACCEPTED: 3024. If ACCEPTED:
465 ├─ Event saved to store 303 ├─ Event saved to database
466 └─ on_event_saved hook triggered 304 └─ ensure_bare_repository() called
467 305
4685. handle_repository_announcement() 3065. Bare Git repository created at
469 ├─ Parse repository details 307 <git_data_path>/<npub>/<identifier>.git
470 ├─ Create Git repository directory
471 ├─ Initialize bare Git repo
472 └─ Configure Git settings
473```
474
475## Key Implementation Details
476
477### 1. Parsing Git Receive-Pack Protocol
478
479The Git receive-pack protocol uses a pkt-line format. We need to parse:
480
481```
4820000-0000-0000-0000 0000-0000-0000-0000 refs/heads/main\0 report-status
4830000-0000-0000-0000 0000-0000-0000-0000 refs/heads/dev
484```
485
486Each line has:
487- Old SHA (40 hex chars)
488- Space
489- New SHA (40 hex chars)
490- Space
491- Ref name
492- Optional capabilities (first line only, after \0)
493
494```rust
495pub struct RefUpdate {
496 pub old_sha: String,
497 pub new_sha: String,
498 pub ref_name: String,
499}
500
501pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> {
502 // Parse pkt-line format
503 // Extract ref updates
504 // Return structured data
505}
506``` 308```
507 309
508### 2. Maintainer Recursion 310### State Event Flow
509
510The maintainer resolution must handle cycles and correctly build the set:
511 311
512```rust
513fn get_maintainers_recursive(
514 events: &[Event],
515 pubkey: &str,
516 identifier: &str,
517 visited: &mut HashSet<String>,
518) -> HashSet<String> {
519 if visited.contains(pubkey) {
520 return HashSet::new();
521 }
522
523 visited.insert(pubkey.to_string());
524
525 let announcement = find_announcement(events, pubkey, identifier);
526 if announcement.is_none() {
527 return HashSet::new();
528 }
529
530 let repo = parse_repository(announcement.unwrap());
531
532 for maintainer in repo.maintainers {
533 get_maintainers_recursive(events, &maintainer, identifier, visited);
534 }
535
536 visited.clone()
537}
538``` 312```
539 3131. Nostr Client → EVENT (Kind 30618)
540### 3. State Event Validation 314
541 3152. Nostr relay receives event
542```rust 316
543fn validate_state_ref( 3173. Nip34WritePolicy::admit_event()
544 state: &RepositoryState, 318 ├─ Check author is in maintainer set
545 ref_update: &RefUpdate, 319 ├─ Validate state structure
546) -> Result<()> { 320 └─ Accept or reject
547 if ref_update.ref_name.starts_with("refs/heads/") { 321
548 let branch_name = &ref_update.ref_name[11..]; 3224. If ACCEPTED and is latest state:
549 if let Some(commit) = state.branches.get(branch_name) { 323 ├─ Align repository refs to match state
550 if commit == &ref_update.new_sha { 324 ├─ Create/update/delete refs as needed
551 return Ok(()); 325 └─ Set HEAD if commit available
552 }
553 return Err(Error::StateMismatch {
554 ref_name: ref_update.ref_name.clone(),
555 expected: commit.clone(),
556 got: ref_update.new_sha.clone(),
557 });
558 }
559 return Err(Error::RefNotInState(ref_update.ref_name.clone()));
560 }
561
562 if ref_update.ref_name.starts_with("refs/tags/") {
563 let tag_name = &ref_update.ref_name[10..];
564 if let Some(commit) = state.tags.get(tag_name) {
565 if commit == &ref_update.new_sha {
566 return Ok(());
567 }
568 return Err(Error::StateMismatch {
569 ref_name: ref_update.ref_name.clone(),
570 expected: commit.clone(),
571 got: ref_update.new_sha.clone(),
572 });
573 }
574 return Err(Error::RefNotInState(ref_update.ref_name.clone()));
575 }
576
577 Err(Error::InvalidRef(ref_update.ref_name.clone()))
578}
579```
580
581### 4. CORS Support
582
583As per GRASP-01, we must support CORS:
584
585```rust
586use actix_cors::Cors;
587
588fn configure_cors() -> Cors {
589 Cors::default()
590 .allow_any_origin()
591 .allowed_methods(vec!["GET", "POST", "OPTIONS"])
592 .allowed_headers(vec!["Content-Type"])
593 .max_age(3600)
594}
595
596// In main.rs
597App::new()
598 .wrap(configure_cors())
599 .configure(git_routes)
600 .configure(nostr_routes)
601``` 326```
602 327
603## Testing Strategy 328## Testing Strategy
604 329
605See [TEST_STRATEGY.md](TEST_STRATEGY.md) for comprehensive testing documentation, including: 330See [test-strategy.md](../reference/test-strategy.md) for comprehensive testing documentation.
606
607- **GRASP Compliance Testing Tool**: Reusable test suite that validates any GRASP implementation against the spec
608- **Spec-Mirrored Tests**: Test structure matches GRASP protocol documents exactly
609- **Clear Failure Messages**: Test failures cite exact spec lines (e.g., "GRASP-01:12-13")
610- **Multiple Test Levels**: Unit, integration, compliance, and end-to-end tests
611 331
612### Quick Overview 332### Quick Overview
613 333
614```rust 334**Integration Tests** ([`tests/`](tests/)):
615// Unit Tests - Individual functions 335- Use [`TestRelay`](tests/common/relay.rs:14) fixture for automatic relay lifecycle
616#[test] 336- Each test file in [`tests/`](tests/) covers a GRASP-01 requirement
617fn test_parse_ref_updates() {
618 let body = b"0000... 0000... refs/heads/main\0report-status\n";
619 let updates = parse_ref_updates(body).unwrap();
620 assert_eq!(updates.len(), 1);
621 assert_eq!(updates[0].ref_name, "refs/heads/main");
622}
623 337
624// Integration Tests - Component interaction 338**Audit Tests** ([`grasp-audit/`](grasp-audit/)):
625#[tokio::test] 339- Reusable compliance testing for any GRASP implementation
626async fn test_full_push_flow() { 340- Spec-mirrored structure in [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/)
627 let app = test_app().await;
628 let (announcement, state) = app.create_repo_with_state()
629 .branch("main", "commit-123")
630 .build()
631 .await;
632
633 let result = app.git_push("main", "commit-123").await;
634 assert!(result.success);
635}
636 341
637// Compliance Tests - GRASP spec validation 342```rust
343// Example: tests/nip01_compliance.rs
638#[tokio::test] 344#[tokio::test]
639async fn test_grasp_01_compliance() { 345async fn test_nip01_websocket_connection() {
640 use grasp_compliance_tests::{TestContext, Grasp01Spec}; 346 let relay = TestRelay::start().await;
641 347 // Test NIP-01 compliance...
642 let ctx = TestContext::builder() 348 relay.stop().await;
643 .base_url(&server.url())
644 .build();
645
646 let results = Grasp01Spec::test_compliance(&ctx).await;
647 assert!(results.all_passed(), "{}", results.report());
648} 349}
649``` 350```
650 351
651The compliance testing tool is designed as a **standalone crate** that can be:
652- Used by ngit-grasp for self-validation
653- Published for other GRASP implementations to use
654- Updated as new GRASP specs are released
655- Run in CI/CD for continuous compliance verification
656
657## Performance Considerations 352## Performance Considerations
658 353
659### 1. Async All The Way 354### 1. Async All The Way
660 355
661- Use `tokio` for all I/O 356- Use `tokio` for all I/O
662- Non-blocking Git subprocess spawning 357- Non-blocking Git subprocess spawning via [`GitSubprocess`](src/git/subprocess.rs)
663- Stream large pack files without buffering 358- Stream large pack files without buffering
664 359
665### 2. Connection Pooling 360### 2. Shared Database
666
667- Reuse Nostr relay connections
668- Connection pool for internal relay queries
669
670### 3. Caching
671 361
672- Cache parsed state events (with TTL) 362- Single database instance shared between relay and Git handlers
673- Cache maintainer sets 363- Direct queries for push authorization (no WebSocket round-trip)
674- Invalidate on new state events
675 364
676```rust 365### 3. Write Policy Caching
677pub struct StateCache {
678 cache: Arc<RwLock<HashMap<String, CachedState>>>,
679}
680
681struct CachedState {
682 state: RepositoryState,
683 maintainers: Vec<String>,
684 timestamp: Instant,
685}
686 366
687impl StateCache { 367- Maintainer sets computed once per event validation
688 pub async fn get_or_fetch( 368- State lookups use database indexes
689 &self,
690 identifier: &str,
691 fetcher: impl Future<Output = Result<(RepositoryState, Vec<String>)>>,
692 ) -> Result<(RepositoryState, Vec<String>)> {
693 // Check cache
694 // Return if fresh
695 // Otherwise fetch and cache
696 }
697}
698```
699 369
700## Future Extensions 370## Future Extensions
701 371
702### GRASP-02: Proactive Sync 372### GRASP-02: Proactive Sync
703 373
704Add background tasks: 374See [grasp-02-proactive-sync.md](grasp-02-proactive-sync.md) for detailed design.
705
706```rust
707pub struct ProactiveSyncTask {
708 relay_client: Client,
709 git_manager: RepositoryManager,
710}
711
712impl ProactiveSyncTask {
713 pub async fn run(&self) {
714 loop {
715 tokio::time::sleep(Duration::from_secs(3600)).await;
716
717 // Fetch all announcements from our relay
718 let announcements = self.fetch_announcements().await;
719
720 for ann in announcements {
721 // Sync events from listed relays
722 self.sync_events(&ann).await;
723
724 // Sync git data from listed clones
725 self.sync_git_data(&ann).await;
726
727 // Fetch PR data
728 self.sync_pr_data(&ann).await;
729 }
730 }
731 }
732}
733```
734 375
735### GRASP-05: Archive 376### GRASP-05: Archive
736 377
737Relax the policy: 378Relax the write policy to accept all repository announcements regardless of clone/relays tags.
738
739```rust
740pub struct ArchiveAnnouncementPolicy;
741
742impl WritePolicy for ArchiveAnnouncementPolicy {
743 fn admit_event(&self, event: &Event, _addr: &SocketAddr)
744 -> BoxFuture<PolicyResult>
745 {
746 // Accept all repository announcements
747 // Don't check clone/relays tags
748 PolicyResult::Accept
749 }
750}
751```
752 379
753## Deployment 380## Deployment
754 381
@@ -756,7 +383,7 @@ impl WritePolicy for ArchiveAnnouncementPolicy {
756 383
757```bash 384```bash
758cargo build --release 385cargo build --release
759./target/release/ngit-grasp 386./target/release/ngit-grasp --domain example.com --owner-npub npub1...
760``` 387```
761 388
762### Docker 389### Docker
@@ -799,10 +426,17 @@ WantedBy=multi-user.target
7992. **Path Traversal**: Prevent directory traversal in repository paths 4262. **Path Traversal**: Prevent directory traversal in repository paths
8003. **DoS Protection**: Rate limiting on both HTTP and WebSocket 4273. **DoS Protection**: Rate limiting on both HTTP and WebSocket
8014. **Resource Limits**: Limit pack file sizes, event sizes 4284. **Resource Limits**: Limit pack file sizes, event sizes
8025. **Nostr Event Validation**: Strict signature verification 4295. **Nostr Event Validation**: Strict signature verification (handled by nostr-relay-builder)
803 430
804## Conclusion 431## Conclusion
805 432
806The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. The Rust ecosystem provides excellent libraries for both Git and Nostr protocols, enabling a high-performance, type-safe implementation. 433The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. Using Hyper for the HTTP layer gives us complete control over request handling, WebSocket upgrades, and CORS headers.
807 434
808The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components. 435The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components.
436
437## Related Documentation
438
439- [Inline Authorization Explanation](inline-authorization.md) - Why we chose this approach
440- [GRASP-02 Proactive Sync](grasp-02-proactive-sync.md) - Future work design
441- [Test Strategy](../reference/test-strategy.md) - Comprehensive testing documentation
442- [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons learned \ No newline at end of file
diff --git a/docs/explanation/inline-authorization.md b/docs/explanation/inline-authorization.md
index 98f6e5a..4538602 100644
--- a/docs/explanation/inline-authorization.md
+++ b/docs/explanation/inline-authorization.md
@@ -150,14 +150,17 @@ rm -rf /tmp/test-repo
150```rust 150```rust
151#[tokio::test] 151#[tokio::test]
152async fn test_unauthorized_push() { 152async fn test_unauthorized_push() {
153 let state = create_test_state().await; 153 let relay = TestRelay::start().await;
154 let result = validate_push(&state, "refs/heads/main", alice_pubkey).await; 154 let result = validate_push(&state, "refs/heads/main", alice_pubkey).await;
155 assert!(result.is_err()); 155 assert!(result.is_err());
156 relay.stop().await;
156} 157}
157``` 158```
158 159
159**Result:** Pure Rust unit tests, no shell scripts, no Git setup. 160**Result:** Pure Rust unit tests, no shell scripts, no Git setup.
160 161
162See [`tests/push_authorization.rs`](tests/push_authorization.rs) for actual test examples.
163
161### 4. Shared State and Types 164### 4. Shared State and Types
162 165
163**With hooks:** 166**With hooks:**
@@ -168,19 +171,17 @@ async fn test_unauthorized_push() {
168 171
169**With inline authorization:** 172**With inline authorization:**
170```rust 173```rust
171pub struct GitHandler { 174// From src/git/handlers.rs
172 nostr_relay: Arc<NostrRelay>, // Shared! 175pub async fn handle_receive_pack(
173 state_cache: Arc<StateCache>, // Shared! 176 repo_path: PathBuf,
174} 177 body: Bytes,
175 178 database: SharedDatabase, // Shared with Nostr relay!
176impl GitHandler { 179 npub: &str,
177 async fn validate_push(&self, refs: &[RefUpdate]) -> Result<()> { 180 identifier: &str,
178 // Direct access to Nostr state 181) -> Result<Response<Full<Bytes>>, GitError> {
179 let state = self.state_cache.get_latest().await?; 182 // Direct database access for authorization
180 // Validate using shared types 183 let auth = get_authorization_for_owner(&database, pubkey, identifier).await?;
181 state.validate_refs(refs)?; 184 // ...
182 Ok(())
183 }
184} 185}
185``` 186```
186 187
@@ -208,9 +209,9 @@ Setup steps:
208**With inline authorization (ngit-grasp):** 209**With inline authorization (ngit-grasp):**
209``` 210```
210Single Rust binary: 211Single Rust binary:
211 - HTTP server (actix-web) 212 - HTTP server (Hyper)
212 - Git protocol handler 213 - Git protocol handler
213 - Nostr relay 214 - Nostr relay (nostr-relay-builder)
214 - Authorization logic 215 - Authorization logic
215 216
216Setup steps: 217Setup steps:
@@ -235,44 +236,38 @@ Content-Type: application/x-git-receive-pack-request
2350000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status 2360000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status
236``` 237```
237 238
238We parse this **before** spawning Git: 239We parse this **before** spawning Git. See [`src/git/authorization.rs`](src/git/authorization.rs) for the implementation:
239 240
240```rust 241```rust
241pub async fn git_receive_pack( 242/// Parse ref updates from git-receive-pack request body
242 req: HttpRequest, 243pub fn parse_pushed_refs(body: &[u8]) -> Result<Vec<PushedRef>, AuthorizationError> {
243 body: web::Bytes, 244 // Parse pkt-line format
244) -> Result<HttpResponse, Error> { 245 // Extract ref updates
245 // 1. Parse ref updates from request body 246 // Return structured data
246 let ref_updates = parse_ref_updates(&body)?;
247
248 // 2. Validate against Nostr state
249 let state = get_latest_state(&repo).await?;
250 validate_push(&state, &ref_updates).await?;
251
252 // 3. If valid, spawn git-receive-pack
253 spawn_git_receive_pack(req, body).await
254} 247}
255``` 248```
256 249
257### How We Validate 250### How We Validate
258 251
259Validation checks: 252Validation checks (from [`src/git/authorization.rs`](src/git/authorization.rs)):
253
2601. Does pusher's pubkey have write access? 2541. Does pusher's pubkey have write access?
2612. Are they listed as a maintainer in the latest state event? 2552. Are they listed as a maintainer in the latest state event?
2623. Do maintainer sets form a valid chain? 2563. Do the refs match the state event?
263 257
264```rust 258```rust
265async fn validate_push( 259/// Validate that pushed refs match the authorized state
266 state: &RepoState, 260pub fn validate_push_refs(
267 refs: &[RefUpdate], 261 pushed_refs: &[PushedRef],
268) -> Result<()> { 262 state: &RepositoryState,
269 for ref_update in refs { 263) -> Result<(), AuthorizationError> {
270 // Check if pusher is authorized for this ref 264 for pushed_ref in pushed_refs {
271 if !state.is_authorized(&ref_update.name, pusher_pubkey) { 265 if pushed_ref.ref_name.starts_with("refs/heads/") {
272 return Err(Error::Unauthorized { 266 // Validate branch against state
273 ref_name: ref_update.name.clone(), 267 } else if pushed_ref.ref_name.starts_with("refs/tags/") {
274 pubkey: pusher_pubkey, 268 // Validate tag against state
275 }); 269 } else if pushed_ref.ref_name.starts_with("refs/nostr/") {
270 // Allow refs/nostr/<event-id> for PRs
276 } 271 }
277 } 272 }
278 Ok(()) 273 Ok(())
@@ -291,7 +286,7 @@ async fn validate_push(
291| **Performance** | Spawns Git first | Validates first | 286| **Performance** | Spawns Git first | Validates first |
292| **Testing** | Shell scripts + Go tests | Pure Rust tests | 287| **Testing** | Shell scripts + Go tests | Pure Rust tests |
293| **Deployment** | Docker + supervisord | Single binary | 288| **Deployment** | Docker + supervisord | Single binary |
294| **State sharing** | WebSocket query | Direct memory access | 289| **State sharing** | WebSocket query | Direct database access |
295 290
296Both are GRASP-compliant, but inline authorization is simpler and more efficient. 291Both are GRASP-compliant, but inline authorization is simpler and more efficient.
297 292
@@ -314,34 +309,25 @@ Both are GRASP-compliant, but inline authorization is simpler and more efficient
314### Is It Worth It? 309### Is It Worth It?
315 310
316**Yes**, because: 311**Yes**, because:
3171. The `git-http-backend` crate handles protocol parsing 3121. We handle protocol parsing in [`src/git/protocol.rs`](src/git/protocol.rs)
3182. GRASP is already non-standard (Nostr authorization) 3132. GRASP is already non-standard (Nostr authorization)
3193. Benefits far outweigh the coupling cost 3143. Benefits far outweigh the coupling cost
3204. We can still add hook support later if needed 3154. We can still add hook support later if needed
321 316
322--- 317---
323 318
324## Alternative Considered: Hybrid Approach 319## Implementation References
325
326We could use **both** inline validation and hooks:
327
328```rust
329// Inline: Fast path for common cases
330if !quick_validate(pusher).await? {
331 return Err(Error::Unauthorized);
332}
333
334// Hook: Detailed validation
335spawn_git_with_hook().await?;
336```
337 320
338**Why we didn't choose this:** 321Key files in the ngit-grasp implementation:
339- Added complexity
340- Redundant validation
341- Slower (two validation steps)
342- Harder to maintain
343 322
344If inline validation is sufficient, why add hooks? 323| Component | Location |
324|-----------|----------|
325| HTTP routing | [`src/http/mod.rs`](src/http/mod.rs) |
326| Git handlers | [`src/git/handlers.rs`](src/git/handlers.rs) |
327| Push authorization | [`src/git/authorization.rs`](src/git/authorization.rs) |
328| Git protocol parsing | [`src/git/protocol.rs`](src/git/protocol.rs) |
329| Subprocess management | [`src/git/subprocess.rs`](src/git/subprocess.rs) |
330| Event acceptance policy | [`src/nostr/builder.rs:51`](src/nostr/builder.rs:51) - `Nip34WritePolicy` |
345 331
346--- 332---
347 333
@@ -365,9 +351,8 @@ This would allow:
365 351
366### If Git Protocol Changes 352### If Git Protocol Changes
367 353
368The `git-http-backend` crate abstracts protocol details. If the Git protocol changes: 354The protocol parsing is isolated in [`src/git/protocol.rs`](src/git/protocol.rs). If the Git protocol changes:
369- Update the crate dependency 355- Update the protocol module
370- Adjust our ref parsing if needed
371- Tests will catch any breakage 356- Tests will catch any breakage
372 357
373--- 358---
@@ -380,11 +365,11 @@ The `git-http-backend` crate abstracts protocol details. If the Git protocol cha
3802. It's more performant (early rejection) 3652. It's more performant (early rejection)
3813. It's easier to test (pure Rust) 3663. It's easier to test (pure Rust)
3824. It's simpler to deploy (single binary) 3674. It's simpler to deploy (single binary)
3835. It enables better integration (shared state) 3685. It enables better integration (shared database)
384 369
385The trade-off (coupling to Git HTTP protocol) is acceptable because: 370The trade-off (coupling to Git HTTP protocol) is acceptable because:
386- The protocol is stable and well-specified 371- The protocol is stable and well-specified
387- The `git-http-backend` crate abstracts details 372- Protocol handling is isolated in one module
388- Benefits far outweigh the cost 373- Benefits far outweigh the cost
389 374
390This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**. 375This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**.
@@ -397,7 +382,8 @@ This decision aligns with our goal of creating a **developer-friendly, productio
397- [Design Decisions](decisions.md) - All architectural choices 382- [Design Decisions](decisions.md) - All architectural choices
398- [Comparison with ngit-relay](comparison.md) - Detailed comparison 383- [Comparison with ngit-relay](comparison.md) - Detailed comparison
399- [Git Protocol Reference](../reference/git-protocol.md) - Protocol details 384- [Git Protocol Reference](../reference/git-protocol.md) - Protocol details
385- [Test Strategy](../reference/test-strategy.md) - How we test this
400 386
401--- 387---
402 388
403*Part of the [ngit-grasp explanation docs](./)* 389*Part of the [ngit-grasp explanation docs](./)* \ No newline at end of file
diff --git a/docs/learnings/grasp-audit.md b/docs/learnings/grasp-audit.md
index 14e5a2b..f4620d9 100644
--- a/docs/learnings/grasp-audit.md
+++ b/docs/learnings/grasp-audit.md
@@ -1,13 +1,13 @@
1# GRASP Audit Tool - Patterns and Learnings 1# GRASP Audit Tool - Patterns and Learnings
2 2
3**Purpose:** Document grasp-audit architecture, patterns, and lessons learned 3**Purpose:** Document grasp-audit architecture, patterns, and lessons learned
4**Last Updated:** November 4, 2025 4**Last Updated:** December 4, 2025
5 5
6--- 6---
7 7
8## Overview 8## Overview
9 9
10`grasp-audit` is a compliance testing tool for GRASP (Git Relays Authorized via Signed-Nostr Proofs) protocol implementations. It tests both Nostr relay compliance (NIP-01) and GRASP-specific functionality. 10`grasp-audit` is a **fully implemented** compliance testing tool for GRASP (Git Relays Authorized via Signed-Nostr Proofs) protocol implementations. It tests both Nostr relay compliance (NIP-01) and GRASP-specific functionality.
11 11
12--- 12---
13 13
@@ -32,10 +32,10 @@
32 32
33**Problem:** Test events pollute the relay and need cleanup without deletion events. 33**Problem:** Test events pollute the relay and need cleanup without deletion events.
34 34
35**Solution:** Use special tags to mark audit events: 35**Solution:** Use special tags to mark audit events (implemented in [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs)):
36 36
37```rust 37```rust
38// Every audit event includes these tags 38// Every audit event includes these tags (added automatically)
39[ 39[
40 ["t", "grasp-audit-test-event"], // Marker 40 ["t", "grasp-audit-test-event"], // Marker
41 ["t", "audit-{run-id}"], // Run isolation 41 ["t", "audit-{run-id}"], // Run isolation
@@ -78,6 +78,8 @@
78 78
79### Audit Configuration 79### Audit Configuration
80 80
81From [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs):
82
81```rust 83```rust
82use grasp_audit::audit::AuditConfig; 84use grasp_audit::audit::AuditConfig;
83 85
@@ -101,100 +103,41 @@ let config = AuditConfig::shared();
101 103
102### Creating Audit Events 104### Creating Audit Events
103 105
104```rust 106From [`grasp-audit/src/client.rs`](grasp-audit/src/client.rs):
105use grasp_audit::audit::{AuditConfig, AuditEventBuilder};
106use nostr_sdk::prelude::*;
107
108let config = AuditConfig::isolated();
109let keys = Keys::generate();
110
111// Create audit event
112let event = AuditEventBuilder::new(&config, Kind::TextNote, "test content")
113 .build(&keys)?;
114
115// Event automatically includes:
116// - Audit marker tag
117// - Run ID tag
118// - Cleanup timestamp tag
119```
120
121---
122
123### Querying Audit Events
124 107
125```rust 108```rust
126use grasp_audit::client::AuditClient; 109use grasp_audit::client::AuditClient;
127use grasp_audit::audit::AuditConfig; 110use grasp_audit::audit::AuditConfig;
128 111
129let config = AuditConfig::isolated(); 112let config = AuditConfig::isolated();
130let client = AuditClient::new(config, keys); 113let client = AuditClient::new("ws://localhost:8080", config).await?;
131
132// Connect to relay
133client.add_relay("ws://localhost:7000").await?;
134client.connect().await;
135
136// Query audit events for this run
137let events = client.query().await?;
138
139// Events are filtered by:
140// - "grasp-audit-test-event" marker
141// - Current run ID
142```
143
144---
145
146### Test Isolation
147
148**Each test run is isolated by unique run ID:**
149
150```rust
151// CI mode generates unique UUID per run
152let config1 = AuditConfig::isolated();
153let config2 = AuditConfig::isolated();
154
155// config1.run_id != config2.run_id
156// Tests won't interfere with each other
157```
158
159**Benefits:**
160 114
161- ✅ Parallel CI/CD runs don't conflict 115// Create and send an event - cleanup tags are added automatically
162- ✅ Can run multiple test suites simultaneously 116let event = client.event_builder()
163- ✅ Easy to identify which run created which events 117 .kind(Kind::TextNote)
164- ✅ Cleanup can target specific runs 118 .content("test content")
165 119 .build(&keys)?;
166---
167
168### Cleanup Strategy
169
170**Two-phase cleanup:**
171
1721. **Automatic expiry** via cleanup timestamp tag
1732. **Manual cleanup** by querying and deleting
174
175```rust
176// Events include cleanup timestamp
177["t", "audit-cleanup-after-1730707200"]
178 120
179// Cleanup process: 121client.send_event(event).await?;
180// 1. Query events with expired cleanup timestamp
181// 2. Delete from database directly (no KIND 5)
182// 3. Avoid deletion event pollution
183``` 122```
184 123
185**Implementation:** To be built in relay (not in audit tool)
186
187--- 124---
188 125
189## Testing Strategy 126### Test Suites
190 127
191### Test Organization 128From [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs):
192 129
193``` 130```
194grasp-audit/src/specs/ 131grasp-audit/src/specs/grasp01/
195├── nip01_smoke.rs # NIP-01 basic functionality 132├── mod.rs # Module exports
196├── grasp_01_relay.rs # GRASP-01 relay requirements (planned) 133├── nip01_smoke.rs # NIP-01 basic functionality
197└── mod.rs # Test suite registry 134├── nip11_document.rs # NIP-11 document tests
135├── event_acceptance_policy.rs # GRASP-01 event rules
136├── cors.rs # CORS header tests
137├── git_clone.rs # Git clone operations
138├── push_authorization.rs # Push validation tests
139├── repository_creation.rs # Repository lifecycle
140└── spec_requirements.rs # Requirement definitions
198``` 141```
199 142
200### Unit vs Integration Tests 143### Unit vs Integration Tests
@@ -229,17 +172,18 @@ mod tests {
229 172
230```bash 173```bash
231# Unit tests (fast, no dependencies) 174# Unit tests (fast, no dependencies)
232cargo test --lib 175cd grasp-audit && nix develop -c cargo test --lib
233 176
234# Integration tests (requires relay) 177# Integration tests (requires relay via test-ngit-relay.sh)
235docker run --rm -p 7000:7000 scsibug/nostr-rs-relay 178cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
236cargo test -- --ignored
237``` 179```
238 180
239--- 181---
240 182
241### Test Result Reporting 183### Test Result Reporting
242 184
185From [`grasp-audit/src/result.rs`](grasp-audit/src/result.rs):
186
243```rust 187```rust
244use grasp_audit::result::AuditResult; 188use grasp_audit::result::AuditResult;
245 189
@@ -255,7 +199,7 @@ for result in &results {
255} 199}
256 200
257// Summary 201// Summary
258let passed = results.iter().filter(|r| r.is_pass()).count(); 202let passed = results.iter().filter(|r| r.passed).count();
259let total = results.len(); 203let total = results.len();
260println!("Results: {}/{} passed ({:.1}%)", 204println!("Results: {}/{} passed ({:.1}%)",
261 passed, total, (passed as f64 / total as f64) * 100.0); 205 passed, total, (passed as f64 / total as f64) * 100.0);
@@ -291,7 +235,7 @@ grasp-audit audit \
291grasp-audit audit \ 235grasp-audit audit \
292 --relay wss://relay.example.com \ 236 --relay wss://relay.example.com \
293 --mode production \ 237 --mode production \
294 --run-id "audit-2025-11-04" \ 238 --run-id "audit-2025-12-04" \
295 --verbose 239 --verbose
296 240
297# Test all specs 241# Test all specs
@@ -366,25 +310,23 @@ let events = client.query().await?;
366 310
367--- 311---
368 312
369## Future Enhancements 313## What's Implemented
370 314
371### Planned Features 315### Completed Features
372
373- [ ] **GRASP-01 Test Suite**: Repository announcement and state event tests
374- [ ] **Test Report Generation**: JSON/HTML output for CI/CD
375- [ ] **Performance Benchmarks**: Measure relay performance
376- [ ] **Relay Comparison**: Side-by-side compliance comparison
377- [ ] **Continuous Monitoring**: Periodic production audits
378 316
379--- 317- ✅ **GRASP-01 Test Suites**: All NIP-01, NIP-11, CORS, event acceptance tests
318- ✅ **Spec Requirements Database**: Machine-readable requirements in [`spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs)
319- ✅ **Automatic Cleanup Tags**: Production-safe event tagging
320- ✅ **Test Isolation**: UUID run IDs for parallel execution
321- ✅ **AuditClient**: Nostr client wrapper with audit features
322- ✅ **Fixture Helpers**: Event creation helpers in [`fixtures.rs`](grasp-audit/src/fixtures.rs)
380 323
381### Possible Improvements 324### Future Enhancements
382 325
383- [ ] **Parallel Test Execution**: Run specs in parallel 326- [ ] **GRASP-02 Test Suite**: Proactive sync tests
384- [ ] **Retry Logic**: Handle transient failures 327- [ ] **HTML Report Generation**: Rich CI/CD reports
385- [ ] **Custom Assertions**: Domain-specific test helpers 328- [ ] **Performance Benchmarks**: Measure relay performance
386- [ ] **Event Diff Tool**: Compare expected vs actual events 329- [ ] **Relay Comparison**: Side-by-side compliance comparison
387- [ ] **Cleanup Automation**: Auto-cleanup after tests
388 330
389--- 331---
390 332
@@ -403,14 +345,12 @@ let events = client.query().await?;
403**Solution:** 345**Solution:**
404 346
405```bash 347```bash
406# Start relay 348# Use test-ngit-relay.sh for automated relay management
407docker run --rm -p 7000:7000 scsibug/nostr-rs-relay 349cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
408
409# Verify relay is running
410curl http://localhost:7000
411 350
412# Run tests 351# Or manually:
413cargo test -- --ignored 352docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest
353RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored
414``` 354```
415 355
416--- 356---
@@ -471,46 +411,39 @@ let config = AuditConfig::isolated();
471let config = AuditConfig::shared(); 411let config = AuditConfig::shared();
472``` 412```
473 413
474### Event Creation
475
476```rust
477let event = AuditEventBuilder::new(&config, kind, content)
478 .build(&keys)?;
479```
480
481### Client Usage 414### Client Usage
482 415
483```rust 416```rust
484let client = AuditClient::new(config, keys); 417let client = AuditClient::new("ws://localhost:7000", config).await?;
485client.add_relay("ws://localhost:7000").await?; 418assert!(client.is_connected().await);
486client.connect().await;
487let events = client.query().await?;
488``` 419```
489 420
490### Running Tests 421### Running Tests
491 422
492```bash 423```bash
493# Unit tests 424# Unit tests (from grasp-audit/)
494cargo test --lib 425nix develop -c cargo test --lib
495 426
496# Integration tests 427# Integration tests with ngit-relay
497cargo test -- --ignored 428nix develop -c bash test-ngit-relay.sh --mode test
498 429
499# CLI 430# CLI audit
500cargo run -- audit --relay ws://localhost:7000 431nix develop -c cargo run -- audit --relay ws://localhost:7000
501``` 432```
502 433
503--- 434### Key Files
504
505## References
506 435
507- **GRASP Protocol**: https://gitworkshop.dev/danconwaydev.com/grasp 436| File | Purpose |
508- **NIP-01**: https://github.com/nostr-protocol/nips/blob/master/01.md 437|------|---------|
509- **NIP-34**: https://github.com/nostr-protocol/nips/blob/master/34.md 438| [`grasp-audit/src/lib.rs`](grasp-audit/src/lib.rs) | Public API |
510- **grasp-audit README**: `grasp-audit/README.md` 439| [`grasp-audit/src/client.rs`](grasp-audit/src/client.rs) | AuditClient implementation |
511- **Tag Migration**: `docs/archive/2025-11-04-tag-migration.md` 440| [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs) | AuditConfig, cleanup tags |
441| [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs) | Test suite registry |
442| [`grasp-audit/src/specs/grasp01/spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs) | Requirement database |
512 443
513--- 444---
514 445
515_Last updated: November 4, 2025_ 446## Related Documentation
516_Status: Living document - update as grasp-audit evolves_ 447
448- [Test Strategy](../reference/test-strategy.md) - Overall testing approach
449- [GRASP-01 Implementation](grasp-01-implementation.md) - Main project learnings \ No newline at end of file
diff --git a/docs/reference/test-strategy.md b/docs/reference/test-strategy.md
index cc1d5b0..7a31bdf 100644
--- a/docs/reference/test-strategy.md
+++ b/docs/reference/test-strategy.md
@@ -2,15 +2,15 @@
2 2
3## Overview 3## Overview
4 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. 5This document describes the testing strategy for ngit-grasp, including the **grasp-audit** reusable compliance testing tool and the **integration tests** in the main repository.
6 6
7## Testing Philosophy 7## Testing Philosophy
8 8
91. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly 91. **Specification-Driven**: Tests mirror GRASP-01 protocol structure exactly
102. **Compliance-First**: Every requirement in the spec has a corresponding test 102. **Compliance-First**: Every requirement in the spec has a corresponding test
113. **Reusable**: Compliance tests can validate any GRASP implementation 113. **Reusable**: The grasp-audit tool can validate any GRASP implementation
124. **Clear Failures**: Test failures cite exact spec lines/sections 124. **Isolated**: Each test runs with its own relay instance via [`TestRelay`](tests/common/relay.rs:14)
135. **Comprehensive**: Unit, integration, and compliance testing 135. **Clear Failures**: Test failures cite exact spec requirements
14 14
15## Test Pyramid 15## Test Pyramid
16 16
@@ -20,1219 +20,294 @@ This document outlines the comprehensive testing strategy for ngit-grasp, includ
20 ╱ E2E╲ ~ 10% End-to-end with real Git 20 ╱ E2E╲ ~ 10% End-to-end with real Git
21 ╱──────╲ 21 ╱──────╲
22 ╱ ╲ 22 ╱ ╲
23 ╱Compliance╲ ~ 20% GRASP spec validation 23 ╱Compliance╲ ~ 30% GRASP-01 spec validation
24 ╱────────────╲ 24 ╱────────────╲ (grasp-audit)
25 ╱ ╲ 25 ╱ ╲
26 ╱ Integration ╲ ~ 30% Component interaction 26 ╱ Integration ╲ ~ 30% Component interaction
27 ╱──────────────────╲ 27 ╱──────────────────╲ (tests/)
28 ╱ ╲ 28 ╱ ╲
29 ╱ Unit Tests ╲ ~ 40% Individual functions 29 ╱ Unit Tests ╲ ~ 30% Individual functions
30 ╱────────────────────────╲ 30 ╱────────────────────────╲ (src/**/tests)
31``` 31```
32 32
33## GRASP Compliance Testing Tool 33## Project Structure
34 34
35### Design Goals 35### Actual Test Layout
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 36
45``` 37```
46grasp-compliance-tests/ 38ngit-grasp/
47├── Cargo.toml # Standalone crate 39├── tests/ # Integration tests for ngit-grasp
48├── README.md # Usage instructions 40│ ├── common/
49├── src/ 41│ │ ├── mod.rs # Test utilities module
50│ ├── lib.rs # Public API 42│ │ └── relay.rs # TestRelay fixture
51│ ├── client.rs # Test client utilities 43│ ├── nip01_compliance.rs # NIP-01 relay compliance
52│ ├── assertions.rs # Spec-based assertions 44│ ├── nip11_document.rs # NIP-11 document tests
53│ └── specs/ 45│ ├── nip34_announcements.rs # Repository announcement tests
54│ ├── mod.rs # Spec registry 46│ ├── repository_creation.rs # Git repo creation tests
55│ ├── grasp_01.rs # GRASP-01 tests 47│ ├── push_authorization.rs # Push validation tests
56│ ├── grasp_02.rs # GRASP-02 tests 48│ ├── cors.rs # CORS header tests
57│ └── grasp_05.rs # GRASP-05 tests 49│ └── git_clone.rs # Git clone tests
58├── fixtures/ 50
59│ ├── repos/ # Test repositories 51└── grasp-audit/ # Reusable GRASP compliance tool
60│ ├── events/ # Nostr event fixtures 52 ├── Cargo.toml
61│ └── keys/ # Test keypairs 53 ├── flake.nix
62└── examples/ 54 └── src/
63 └── test_implementation.rs # Example usage 55 ├── lib.rs # Public API
56 ├── client.rs # AuditClient
57 ├── audit.rs # AuditConfig, cleanup tags
58 ├── fixtures.rs # Test fixtures
59 └── specs/
60 └── grasp01/ # GRASP-01 specification tests
61 ├── mod.rs # Module exports
62 ├── nip01_smoke.rs # NIP-01 smoke tests
63 ├── nip11_document.rs
64 ├── event_acceptance_policy.rs
65 ├── cors.rs
66 ├── git_clone.rs
67 ├── push_authorization.rs
68 ├── repository_creation.rs
69 └── spec_requirements.rs # Requirement definitions
64``` 70```
65 71
66### Spec-Mirrored Test Structure 72## Integration Tests (tests/)
73
74### TestRelay Fixture
67 75
68Each GRASP spec document maps to a test module with identical structure: 76The [`TestRelay`](tests/common/relay.rs:14) fixture provides automatic relay lifecycle management:
69 77
70```rust 78```rust
71// src/specs/grasp_01.rs 79// From tests/common/relay.rs
72 80
73use crate::{TestContext, SpecRequirement, ComplianceResult}; 81/// Test relay fixture that manages relay lifecycle
74 82///
75/// GRASP-01 - Core Service Requirements 83/// Automatically starts and stops the ngit-grasp relay for testing.
76/// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md 84/// Uses a random port to avoid conflicts and cleans up created repositories.
77pub struct Grasp01Spec; 85pub struct TestRelay {
78 86 process: Child,
79impl Grasp01Spec { 87 url: String,
80 /// Run all GRASP-01 compliance tests 88 port: u16,
81 pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult { 89}
82 let mut results = ComplianceResult::new("GRASP-01"); 90
83 91impl TestRelay {
84 // Section: Nostr Relay 92 /// Start a test relay instance
85 results.add(Self::test_nostr_relay_nip01_compliance(ctx).await); 93 pub async fn start() -> Self { ... }
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 94
499 // ================================================================ 95 /// Get the relay WebSocket URL
500 // CORS SUPPORT TESTS 96 pub fn url(&self) -> &str { ... }
501 // ================================================================
502 97
503 /// MUST set Access-Control-Allow-Origin: * on ALL responses 98 /// Get the relay domain (host:port)
504 /// 99 pub fn domain(&self) -> String { ... }
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 100
534 /// MUST respond to OPTIONS requests with 204 No Content 101 /// Stop the relay
535 /// 102 pub async fn stop(mut self) { ... }
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} 103}
557``` 104```
558 105
559### Test Result Reporting 106### Using TestRelay in Integration Tests
107
108From [`tests/nip01_compliance.rs`](tests/nip01_compliance.rs):
560 109
561```rust 110```rust
562/// Test result with spec citation 111use common::TestRelay;
563pub struct TestResult { 112use grasp_audit::*;
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 113
572impl TestResult { 114/// Macro to generate isolated integration tests
573 /// Create a new test result 115macro_rules! isolated_test {
574 pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { 116 ($test_name:ident) => {
575 TestResult { 117 #[tokio::test]
576 name: name.to_string(), 118 async fn $test_name() {
577 spec_ref: spec_ref.to_string(), 119 let relay = TestRelay::start().await;
578 requirement: requirement.to_string(), 120 let config = AuditConfig::isolated();
579 passed: false, 121 let client = AuditClient::new(relay.url(), config)
580 error: None, 122 .await
581 duration: Duration::default(), 123 .expect("Failed to create audit client");
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 124
608/// Collection of test results for a spec 125 let result = specs::Nip01SmokeTests::$test_name(&client).await;
609pub struct ComplianceResult { 126
610 pub spec: String, 127 relay.stop().await;
611 pub results: Vec<TestResult>,
612}
613 128
614impl ComplianceResult { 129 assert!(
615 pub fn report(&self) -> String { 130 result.passed,
616 let mut output = String::new(); 131 "{} failed: {}",
617 132 stringify!($test_name),
618 output.push_str(&format!("\n{} Compliance Report\n", self.spec)); 133 result.error.as_deref().unwrap_or("unknown error")
619 output.push_str(&"=".repeat(60)); 134 );
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 } 135 }
643 136 };
644 output
645 }
646} 137}
647```
648 138
649### Usage Example 139// Generate isolated tests for all NIP-01 smoke tests
650 140isolated_test!(test_websocket_connection);
651```rust 141isolated_test!(test_send_receive_event);
652// examples/test_implementation.rs 142isolated_test!(test_create_subscription);
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``` 143```
677 144
678### Integration with ngit-grasp 145### Running Integration Tests
679 146
680In `ngit-grasp/tests/compliance.rs`: 147```bash
148# Run all integration tests
149cargo test --test '*'
681 150
682```rust 151# Run specific test file
683use grasp_compliance_tests::{TestContext, Grasp01Spec}; 152cargo test --test nip01_compliance
684 153
685#[tokio::test] 154# Run with output
686async fn test_grasp_01_compliance() { 155cargo test --test nip01_compliance -- --nocapture
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``` 156```
708 157
709## Unit Testing Strategy 158## GRASP Audit Tool (grasp-audit/)
710 159
711### Git Module Tests 160### Purpose
712 161
713```rust 162The grasp-audit tool is a **reusable GRASP compliance testing library** that can:
714// src/git/parser.rs tests
715 163
716#[cfg(test)] 164- Test ngit-grasp for self-validation
717mod tests { 165- Test any other GRASP implementation (like ngit-relay)
718 use super::*; 166- Run in CI/CD for continuous compliance verification
719 167- Generate compliance reports
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 168
749### Authorization Module Tests 169### Test Suites
750 170
751```rust 171From [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs):
752// src/git/authorization.rs tests
753 172
754#[cfg(test)] 173| Suite | Description | Requirements |
755mod tests { 174|-------|-------------|--------------|
756 use super::*; 175| [`Nip01SmokeTests`](grasp-audit/src/specs/grasp01/nip01_smoke.rs) | Basic NIP-01 relay functionality | WebSocket only |
757 176| [`Nip11DocumentTests`](grasp-audit/src/specs/grasp01/nip11_document.rs) | NIP-11 relay information document | WebSocket only |
758 #[test] 177| [`EventAcceptancePolicyTests`](grasp-audit/src/specs/grasp01/event_acceptance_policy.rs) | Event acceptance rules | WebSocket only |
759 fn test_get_maintainers_single() { 178| [`CorsTests`](grasp-audit/src/specs/grasp01/cors.rs) | CORS headers on Git HTTP endpoints | git-data-dir |
760 let events = vec![ 179| [`GitCloneTests`](grasp-audit/src/specs/grasp01/git_clone.rs) | Git clone operations | git-data-dir |
761 create_test_announcement("alice", "repo1", vec![]), 180| [`PushAuthorizationTests`](grasp-audit/src/specs/grasp01/push_authorization.rs) | Push authorization | git-data-dir |
762 ]; 181| [`RepositoryCreationTests`](grasp-audit/src/specs/grasp01/repository_creation.rs) | Repository creation | git-data-dir |
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 182
829## Integration Testing Strategy 183### Spec Requirements Database
830 184
831### Repository Lifecycle Tests 185From [`grasp-audit/src/specs/grasp01/spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs):
832 186
833```rust 187```rust
834// tests/integration/repository_lifecycle.rs 188pub struct SpecRequirement {
835 189 pub id: &'static str, // e.g., "GRASP-01:L9"
836#[tokio::test] 190 pub section: &'static str, // e.g., "Nostr Relay"
837async fn test_repository_creation_on_announcement() { 191 pub level: RequirementLevel, // MUST, SHOULD, MAY
838 let app = test_app().await; 192 pub text: &'static str, // Exact text from spec
839 193 pub line: u32, // Line number in spec
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} 194}
862 195
863#[tokio::test] 196pub enum RequirementLevel {
864async fn test_push_validation_flow() { 197 Must,
865 let app = test_app().await; 198 Should,
866 199 May,
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} 200}
882``` 201```
883 202
884### Multi-Maintainer Tests 203### Automatic Cleanup Tags
204
205All audit events include cleanup tags for production safety (from [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs)):
885 206
886```rust 207```rust
887#[tokio::test] 208// Automatically added to EVERY audit event:
888async fn test_multi_maintainer_push() { 209["t", "grasp-audit-test-event"] // Marker
889 let app = test_app().await; 210["t", "audit-{run_id}"] // Run isolation
890 211["t", "audit-cleanup-after-{unix_timestamp}"] // Cleanup time
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``` 212```
912 213
913## End-to-End Testing 214### Running grasp-audit
914 215
915### Real Git Client Tests 216**Testing the reference implementation (ngit-relay):**
916 217
917```rust 218```bash
918// tests/e2e/git_client.rs 219# Use test-ngit-relay.sh for automated relay management
919 220cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
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 221
950#[tokio::test] 222# Or manually:
951async fn test_real_git_push() { 223docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest
952 let app = test_app().await; 224cd grasp-audit
953 225RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture
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``` 226```
983 227
984## Performance Testing 228**Testing ngit-grasp (the main project):**
985 229
986### Load Tests 230```bash
231# Integration tests use TestRelay fixture - just run:
232cargo test --test '*'
233```
987 234
988```rust 235## Test Patterns
989// tests/performance/load.rs
990 236
991#[tokio::test] 237### Isolated Test Pattern
992async fn test_concurrent_pushes() { 238
993 let app = test_app().await; 239Each test runs with its own fresh relay instance:
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 240
241```rust
1019#[tokio::test] 242#[tokio::test]
1020async fn test_event_ingestion_throughput() { 243async fn test_something() {
1021 let app = test_app().await; 244 // Start fresh relay
1022 245 let relay = TestRelay::start().await;
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 246
1033 let duration = start.elapsed(); 247 // Run test
1034 let throughput = num_events as f64 / duration.as_secs_f64(); 248 let client = AuditClient::new(relay.url(), AuditConfig::isolated()).await?;
249 // ... test logic ...
1035 250
1036 println!("Event throughput: {:.2} events/sec", throughput); 251 // Cleanup
1037 assert!(throughput > 100.0, "Throughput too low"); 252 relay.stop().await;
1038} 253}
1039``` 254```
1040 255
1041## Test Utilities 256### Macro-Based Test Generation
1042 257
1043### Test Fixtures 258For test suites that follow the same pattern, use macros:
1044 259
1045```rust 260```rust
1046// tests/common/fixtures.rs 261macro_rules! isolated_test {
1047 262 ($test_name:ident) => {
1048pub struct TestEventBuilder { 263 #[tokio::test]
1049 kind: Kind, 264 async fn $test_name() {
1050 content: String, 265 let relay = TestRelay::start().await;
1051 tags: Vec<Tag>, 266 // ... standard setup and teardown ...
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 } 267 }
1063 } 268 };
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} 269}
1126```
1127 270
1128## CI/CD Integration 271isolated_test!(test_websocket_connection);
1129 272isolated_test!(test_send_receive_event);
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``` 273```
1179 274
1180## Test Coverage 275## Coverage Targets
1181 276
1182### Target Coverage 277| Test Type | Coverage Target |
278|-----------|-----------------|
279| Unit Tests | >80% line coverage of `src/` |
280| Integration Tests | All critical user paths |
281| GRASP-01 Compliance | 100% of MUST requirements |
1183 282
1184- **Unit Tests**: >80% line coverage 283## CI/CD Integration
1185- **Integration Tests**: All critical paths
1186- **Compliance Tests**: 100% of GRASP-01 requirements
1187- **E2E Tests**: Key user workflows
1188 284
1189### Measuring Coverage 285### Running All Tests
1190 286
1191```bash 287```bash
1192# Install tarpaulin 288# Unit tests (fast, no external dependencies)
1193cargo install cargo-tarpaulin 289cargo test --lib
1194 290
1195# Run with coverage 291# Integration tests (requires relay binary built)
1196cargo tarpaulin --out Html --output-dir coverage 292cargo build --release
293cargo test --test '*'
1197 294
1198# View report 295# Compliance tests against ngit-relay reference
1199open coverage/index.html 296cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
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``` 297```
1223 298
1224## Summary 299## Summary
1225 300
1226This comprehensive test strategy ensures: 301| What | Where | Purpose |
302|------|-------|---------|
303| Unit tests | `src/**/tests` modules | Test individual functions |
304| Integration tests | `tests/*.rs` | Test ngit-grasp as a whole |
305| TestRelay fixture | [`tests/common/relay.rs`](tests/common/relay.rs) | Manage relay lifecycle |
306| GRASP audit library | `grasp-audit/` | Reusable compliance testing |
307| GRASP-01 specs | [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/) | Spec requirement tests |
1227 308
12281. **Spec Compliance**: Every GRASP requirement has a corresponding test 309## Related Documentation
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 310
1234The compliance testing tool is a standalone crate that can be: 311- [Architecture](../explanation/architecture.md) - System design
1235- Used by ngit-grasp for self-validation 312- [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons
1236- Published for other GRASP implementations to use 313- [GRASP Audit Learnings](../learnings/grasp-audit.md) - Audit tool patterns \ No newline at end of file
1237- Updated as new GRASP specs are released (GRASP-02, GRASP-05)
1238- Run in CI/CD for continuous compliance verification