upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-05 20:13:22 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-05 20:25:38 +0000
commit396da002fefeeb4549e11ff51abf824e91a6ed88 (patch)
treed5a61d081d97b52a38f7ce8f4a28d8c200eeede3 /grasp-audit
parentb22cb23928ef799b0a5d362003d3084d2ab267b4 (diff)
restructure grasp01 audit tests and add event acceptance
Diffstat (limited to 'grasp-audit')
-rw-r--r--grasp-audit/src/client.rs76
-rw-r--r--grasp-audit/src/specs/grasp01/event_acceptance_policy.rs995
-rw-r--r--grasp-audit/src/specs/grasp01/mod.rs9
-rw-r--r--grasp-audit/src/specs/grasp01/nip01_smoke.rs (renamed from grasp-audit/src/specs/nip01_smoke.rs)0
-rw-r--r--grasp-audit/src/specs/grasp01/nip11_document.rs165
-rw-r--r--grasp-audit/src/specs/grasp01_nostr_relay.rs717
-rw-r--r--grasp-audit/src/specs/mod.rs11
7 files changed, 1252 insertions, 721 deletions
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs
index cbefeb9..aed3058 100644
--- a/grasp-audit/src/client.rs
+++ b/grasp-audit/src/client.rs
@@ -232,6 +232,82 @@ impl AuditClient {
232 232
233 Ok(event) 233 Ok(event)
234 } 234 }
235
236 /// Create an issue (kind 1621) that references a repository
237 ///
238 /// # Arguments
239 /// * `repo_event` - The repository announcement event to reference
240 /// * `issue_title` - The subject/title of the issue
241 /// * `content` - The issue content/description
242 /// * `additional_tags` - Optional additional tags (e.g., for quoting other events)
243 ///
244 /// # Returns
245 /// A built and signed Event ready to be sent to the relay
246 pub fn create_issue(
247 &self,
248 repo_event: &Event,
249 issue_title: &str,
250 content: &str,
251 additional_tags: Vec<Tag>,
252 ) -> Result<Event> {
253 // Extract repo_id from the d tag
254 let repo_id = repo_event.tags.iter()
255 .find(|t| t.kind() == TagKind::d())
256 .and_then(|t| t.content())
257 .ok_or_else(|| anyhow!("Repository event must have a 'd' tag"))?
258 .to_string();
259
260 let repo_pubkey = repo_event.pubkey;
261 let a_tag_value = format!("30617:{}:{}", repo_pubkey, repo_id);
262
263 let mut tags = vec![
264 Tag::custom(TagKind::custom("a"), vec![a_tag_value]),
265 Tag::custom(TagKind::custom("subject"), vec![issue_title]),
266 ];
267
268 // Add any additional tags
269 tags.extend(additional_tags);
270
271 self.event_builder(Kind::Custom(1621), content)
272 .tags(tags)
273 .build(self.keys())
274 .map_err(|e| anyhow!("Failed to build issue event: {}", e))
275 }
276
277 /// Create a NIP-22 comment (kind 1111) for an event
278 ///
279 /// # Arguments
280 /// * `event` - The event to comment on
281 /// * `content` - The comment content
282 /// * `additional_tags` - Optional additional tags
283 ///
284 /// # Returns
285 /// A built and signed Event ready to be sent to the relay
286 pub fn create_comment(
287 &self,
288 event: &Event,
289 content: &str,
290 additional_tags: Vec<Tag>,
291 ) -> Result<Event> {
292 let event_kind = event.kind;
293 let event_pubkey = event.pubkey;
294 let event_id = event.id;
295
296 let mut tags = vec![
297 Tag::custom(TagKind::custom("E"), vec![event_id.to_hex(), "".to_string(), "root".to_string()]),
298 Tag::event(event_id),
299 Tag::custom(TagKind::custom("K"), vec![event_kind.as_u16().to_string()]),
300 Tag::public_key(event_pubkey),
301 ];
302
303 // Add any additional tags
304 tags.extend(additional_tags);
305
306 self.event_builder(Kind::Custom(1111), content)
307 .tags(tags)
308 .build(self.keys())
309 .map_err(|e| anyhow!("Failed to build comment event: {}", e))
310 }
235} 311}
236 312
237#[cfg(test)] 313#[cfg(test)]
diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs
new file mode 100644
index 0000000..9294b50
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs
@@ -0,0 +1,995 @@
1//! GRASP-01 Nostr Event Acceptance Policy
2//!
3//! Tests for GRASP-01 Nostr event acceptance policy (lines 3-7 of ../grasp/01.md)
4//!
5//! This file validates that a GRASP-01 compliant relay:
6//! - Accepts valid NIP-34 repository announcements listing the service
7//! - Rejects announcements that don't list the service in clone and relays tags
8//! - Accepts repository state announcements
9//! - Accepts events that TAG accepted repositories
10//! - Accepts events that ARE TAGGED BY accepted events (transitive)
11//!
12//! ## Running Tests
13//!
14//! ### Recommended: Automated Relay Management
15//! ```bash
16//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
17//! ```
18//! This script automatically starts a relay, runs all tests, and cleans up.
19//!
20//! ### Manual Testing (if needed)
21//! ```bash
22//! # 1. Start ngit-relay in a separate terminal:
23//! docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest
24//!
25//! # 2. Run all ignored tests (includes these smoke tests):
26//! RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture
27//!
28//! # 3. Run ONLY these specific smoke tests:
29//! RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib test_grasp01_event_acceptance -- --ignored --nocapture
30//! ```
31//!
32//! ## Test Groups (12 total tests, target <5s execution)
33//!
34//! ### Group 1: Accept Events Tagging Accepted Repositories (3 tests)
35//! - **Test 1.1**: Issue via `a` tag → Validates NIP-33 addressable event references
36//! - **Test 1.2**: Comment via `A` tag → Validates NIP-22 root addressable references
37//! - **Test 1.3**: Kind 1 via `q` tag → Validates NIP-18 quote references
38//!
39//! ### Group 2: Accept Events Tagging Accepted Events - Transitive (3 tests)
40//! - **Test 2.1**: Issue quoting issue via `q` → Multi-hop transitive acceptance
41//! - **Test 2.2**: Comment via `E` tag → NIP-22 threaded root references
42//! - **Test 2.3**: Kind 1 via `e` tag → Standard NIP-01 reply chains
43//!
44//! ### Group 3: Accept Events Tagged BY Accepted Events - Forward References (3 tests)
45//! - **Test 3.1**: Kind 1 referenced in issue → Forward reference acceptance
46//! - **Test 3.2**: Comment referenced in comment → Nested forward references
47//! - **Test 3.3**: Kind 1 referenced in Kind 1 → Cross-event forward refs
48//!
49//! ### Group 4: Reject Unrelated Events (3 tests)
50//! - **Test 4.1**: Orphan issue → No repo connection
51//! - **Test 4.2**: Orphan Kind 1 → No accepted event references
52//! - **Test 4.3**: Comment quoting other repo → Wrong repository context
53//!
54//! ## Test Coverage Summary
55//!
56//! **Tag Types Validated:**
57//! - `a` tags (NIP-33 addressable events)
58//! - `A` tags (NIP-22 root addressable)
59//! - `q` tags (NIP-18 quotes)
60//! - `e` tags (NIP-01 event references)
61//! - `E` tags (NIP-22 root event references)
62//!
63//! **Acceptance Paths:**
64//! - Direct repository references (tags accepted repos)
65//! - Transitive acceptance (tags events that tag accepted repos)
66//! - Forward references (late-arriving events tagged by accepted events)
67//! - Rejection cases (unrelated events with no connection)
68//!
69//! **Helper Functions (6 total):**
70//! - `extract_d_tag()` - Extract identifier from events
71//! - `create_test_repo()` - Create repository announcements
72//! - `create_issue_for_repo()` - Create issues referencing repos
73//! - `create_comment_for_event()` - Create NIP-22 comments
74//! - `send_and_verify_accepted()` - Verify event acceptance
75//! - `send_and_verify_rejected()` - Verify event rejection
76//!
77//! ## Performance Target
78//!
79//! All 12 tests should complete in **under 5 seconds** total when run against
80//! a local ngit-relay instance. Each test includes a 100ms sleep for relay
81//! propagation, so total theoretical minimum is ~1.2s for serial execution.
82//!
83//! ## Implementation Notes
84//!
85//! - Tests use the audit client which automatically adds cleanup tags
86//! - All events are tagged for production relay cleanup
87//! - Tests are designed to be independent and can run in any order
88//! - Forward reference tests verify out-of-order event acceptance
89//! - Transitive tests verify multi-hop acceptance chains
90
91use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp};
92use crate::{AuditClient, AuditResult, TestResult};
93use std::time::Duration;
94
95/// Test suite for GRASP-01 event acceptance policy
96pub struct EventAcceptancePolicyTests;
97
98impl EventAcceptancePolicyTests {
99 /// Run all event acceptance policy tests
100 pub async fn run_all(client: &AuditClient) -> AuditResult {
101 let mut results = AuditResult::new("GRASP-01 Nostr Event Acceptance Policy Tests");
102
103 // Repository Announcement Acceptance Tests
104 results.add(Self::test_accept_valid_repo_announcement(client).await);
105 results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await);
106 results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await);
107
108 // Repository State Announcement Tests
109 results.add(Self::test_accept_valid_repo_state_announcement(client).await);
110
111 // Group 1: Accept Events Tagging Accepted Repositories
112 results.add(Self::test_accept_issue_via_a_tag(client).await);
113 results.add(Self::test_accept_comment_via_A_tag(client).await);
114 results.add(Self::test_accept_kind1_via_q_tag(client).await);
115
116 // Group 2: Accept Events Tagging Accepted Events (Transitive)
117 results.add(Self::test_accept_issue_quoting_issue_via_q(client).await);
118 results.add(Self::test_accept_comment_via_E_tag(client).await);
119 results.add(Self::test_accept_kind1_via_e_tag(client).await);
120
121 // Group 3: Accept Events Tagged BY Accepted Events (Forward Refs)
122 results.add(Self::test_accept_kind1_referenced_in_issue(client).await);
123 results.add(Self::test_accept_comment_referenced_in_comment(client).await);
124 results.add(Self::test_accept_kind1_referenced_in_kind1(client).await);
125
126 // Group 4: Reject Unrelated Events
127 results.add(Self::test_reject_orphan_issue(client).await);
128 results.add(Self::test_reject_orphan_kind1(client).await);
129 results.add(Self::test_reject_comment_quoting_other_repo(client).await);
130
131 results
132 }
133
134 // ============================================================
135 // Repository Announcement Acceptance Tests
136 // ============================================================
137
138 /// Test: Accept valid repository announcements
139 ///
140 /// Spec: Lines 3-5 of ../grasp/01.md
141 /// Requirement: MUST accept repo announcements listing service in clone & relays tags
142 async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult {
143 TestResult::new(
144 "accept_valid_repo_announcement",
145 "GRASP-01:nostr-relay:3-5",
146 "Accept valid repository announcements with service in clone and relays tags",
147 )
148 .run(|| async {
149 // Create a NIP-34 repository announcement event
150 let event = client.create_repo_announcement("accept_valid_repo_announcement").await
151 .map_err(|e| format!("Failed to create repository announcement: {}", e))?;
152
153 // Get relay URL for validation
154 let relay_url = client.client().relays().await
155 .keys()
156 .next()
157 .ok_or("No relay connected")?
158 .to_string();
159
160 // Convert WebSocket URL to HTTP URL for validation
161 let http_url = relay_url
162 .replace("ws://", "http://")
163 .replace("wss://", "https://");
164
165 // Extract repo_id from the event's d tag
166 let repo_id = event.tags.iter()
167 .find(|t| t.kind() == TagKind::d())
168 .and_then(|t| t.content())
169 .ok_or("Missing d tag in announcement")?
170 .to_string();
171
172 // Send the event
173 let event_id = client.send_event(event.clone()).await
174 .map_err(|e| format!("Failed to send repository announcement to relay: {}", e))?;
175
176 // Query back to verify it was accepted and stored
177 let filter = Filter::new()
178 .kind(Kind::GitRepoAnnouncement)
179 .author(client.public_key())
180 .identifier(&repo_id);
181
182 let events = client.query(filter).await
183 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
184
185 // Verify we got the event back
186 if events.is_empty() {
187 return Err(format!(
188 "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}",
189 event_id, repo_id
190 ));
191 }
192
193 // Verify it's the same event
194 let stored_event = events.iter()
195 .find(|e| e.id == event_id)
196 .ok_or(format!(
197 "Stored event ID doesn't match sent event. Expected: {}, Got {} events",
198 event_id, events.len()
199 ))?;
200
201 // Verify key tags are present
202 let has_clone_tag = stored_event.tags.iter()
203 .any(|t| {
204 t.kind() == TagKind::Custom("clone".into()) &&
205 t.content().map(|c| c.contains(&http_url)).unwrap_or(false)
206 });
207
208 let has_relays_tag = stored_event.tags.iter()
209 .any(|t| {
210 t.kind() == TagKind::Custom("relays".into()) &&
211 t.content() == Some(&relay_url)
212 });
213
214 if !has_clone_tag {
215 return Err(format!("Stored event missing clone tag with service URL ({})", http_url));
216 }
217
218 if !has_relays_tag {
219 return Err(format!("Stored event missing relays tag with service URL ({})", relay_url));
220 }
221
222 Ok(())
223 })
224 .await
225 }
226
227 /// Test: Reject repo announcements not listing service in clone tag
228 ///
229 /// Spec: Line 5 of ../grasp/01.md
230 /// Requirement: MUST reject announcements not listing service (unless GRASP-05)
231 async fn test_reject_repo_announcement_missing_clone_tag(client: &AuditClient) -> TestResult {
232 TestResult::new(
233 "reject_repo_announcement_missing_clone_tag",
234 "GRASP-01:nostr-relay:5",
235 "Reject repository announcements without service in clone tag",
236 )
237 .run(|| async {
238 // Get relay URL from client
239 let relay_url = client.client().relays().await
240 .keys()
241 .next()
242 .ok_or("No relay connected - client has no active relay connections")?
243 .to_string();
244
245 // Create unique repository identifier
246 let timestamp = Timestamp::now().as_u64();
247 let repo_id = format!("test-repo-no-clone-{}", timestamp);
248
249 // Create repo announcement WITHOUT service in clone tag
250 let event = client.event_builder(Kind::GitRepoAnnouncement, "")
251 .tag(Tag::identifier(&repo_id))
252 .tag(Tag::custom(TagKind::Custom("name".into()), vec!["Test Repo No Clone"]))
253 .tag(Tag::custom(TagKind::Custom("clone".into()), vec!["https://github.com/user/repo.git"])) // NOT this service
254 .tag(Tag::custom(TagKind::Custom("relays".into()), vec![relay_url.clone()])) // Correct relay
255 .build(client.keys())
256 .map_err(|e| format!("Failed to build event: {}", e))?;
257
258 let event_id = event.id;
259
260 // Send event - expect rejection
261 let send_result = client.send_event(event.clone()).await;
262
263 // Query to verify event is NOT stored
264 let filter = Filter::new()
265 .kind(Kind::GitRepoAnnouncement)
266 .author(client.public_key())
267 .identifier(&repo_id);
268
269 let events = client.query(filter).await
270 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
271
272 // Verify event was rejected (not stored)
273 if events.iter().any(|e| e.id == event_id) {
274 return Err(format!(
275 "Relay incorrectly accepted announcement without service in clone tag. \
276 Event ID: {}, Clone URL: https://github.com/user/repo.git (should require {})",
277 event_id, relay_url
278 ));
279 }
280
281 Ok(())
282 })
283 .await
284 }
285
286 /// Test: Reject repo announcements not listing service in relays tag
287 ///
288 /// Spec: Line 5 of ../grasp/01.md
289 /// Requirement: MUST reject announcements not listing service in relays
290 async fn test_reject_repo_announcement_missing_relays_tag(client: &AuditClient) -> TestResult {
291 TestResult::new(
292 "reject_repo_announcement_missing_relays_tag",
293 "GRASP-01:nostr-relay:5",
294 "Reject repository announcements without service in relays tag",
295 )
296 .run(|| async {
297 // Get relay URL from client
298 let relay_url = client.client().relays().await
299 .keys()
300 .next()
301 .ok_or("No relay connected - client has no active relay connections")?
302 .to_string();
303
304 // Convert WebSocket URL to HTTP URL for clone tag
305 let http_url = relay_url
306 .replace("ws://", "http://")
307 .replace("wss://", "https://");
308
309 // Create unique repository identifier
310 let timestamp = Timestamp::now().as_u64();
311 let repo_id = format!("test-repo-no-relays-{}", timestamp);
312
313 // Create repo announcement WITHOUT service in relays tag
314 let event = client.event_builder(Kind::GitRepoAnnouncement, "")
315 .tag(Tag::identifier(&repo_id))
316 .tag(Tag::custom(TagKind::custom("name"), vec!["Test Repo No Relays"]))
317 .tag(Tag::custom(TagKind::custom("clone"), vec![format!("{}/{}/test-repo.git", http_url, client.public_key())])) // Correct clone
318 .tag(Tag::custom(TagKind::custom("relays"), vec!["wss://relay.damus.io"])) // NOT this service
319 .build(client.keys())
320 .map_err(|e| format!("Failed to build event: {}", e))?;
321
322 let event_id = event.id;
323
324 // Send event - expect rejection
325 let _send_result = client.send_event(event.clone()).await;
326
327 // Query to verify event is NOT stored
328 let filter = Filter::new()
329 .kind(Kind::GitRepoAnnouncement)
330 .author(client.public_key())
331 .identifier(&repo_id);
332
333 let events = client.query(filter).await
334 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
335
336 // Verify event was rejected (not stored)
337 if events.iter().any(|e| e.id == event_id) {
338 return Err(format!(
339 "Relay incorrectly accepted announcement without service in relays tag. \
340 Event ID: {}, Relays URL: wss://relay.damus.io (should require {})",
341 event_id, relay_url
342 ));
343 }
344
345 Ok(())
346 })
347 .await
348 }
349
350 // ============================================================
351 // Repository State Announcement Tests
352 // ============================================================
353
354 /// Test: Accept valid repository state announcements
355 ///
356 /// Spec: Lines 6-7 of ../grasp/01.md
357 /// Requirement: MUST accept repo state announcements with d, maintainers, and r tags
358 async fn test_accept_valid_repo_state_announcement(client: &AuditClient) -> TestResult {
359 TestResult::new(
360 "accept_valid_repo_state_announcement",
361 "GRASP-01:nostr-relay:6-7",
362 "Accept valid state announcements after repo announcement accepted",
363 )
364 .run(|| async {
365 // First, create a repository announcement (kind 30617) by the same author
366 let test_name = format!("test-repo-multi-refs-{}", Timestamp::now().as_u64());
367 let repo_event = client.create_repo_announcement(&test_name).await
368 .map_err(|e| format!("Failed to create repository announcement: {}", e))?;
369
370 // Extract repo_id from the repository announcement
371 let repo_id = repo_event.tags.iter()
372 .find(|t| t.kind() == TagKind::d())
373 .and_then(|t| t.content())
374 .ok_or("Missing d tag in repository announcement")?
375 .to_string();
376
377 // Note: npub not used in this test, removed unused variable
378
379 // Create kind 30618 repository state announcement with multiple refs
380 // Format: ["r", "refs/heads/main", "<commit-id>"]
381 let event = client.event_builder(Kind::Custom(30618), "")
382 .tag(Tag::identifier(&repo_id))
383 .tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![
384 "abc123def456789012345678901234567890abcd"
385 ]))
386 .tag(Tag::custom(TagKind::custom("refs/heads/develop"), vec![
387 "def456789012345678901234567890abcdef123"
388 ]))
389 .tag(Tag::custom(TagKind::custom("refs/tags/v1.0.0"), vec![
390 "123456789012345678901234567890abcdef456"
391 ]))
392 .tag(Tag::custom(TagKind::custom("HEAD"), vec![
393 "ref: refs/heads/main"
394 ]))
395 .build(client.keys())
396 .map_err(|e| format!("Failed to build state announcement: {}", e))?;
397
398 let event_id = event.id;
399
400 // Send the repo announcement event
401 client.send_event(repo_event.clone()).await
402 .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?;
403
404 // Send the state event
405 client.send_event(event.clone()).await
406 .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?;
407
408 // Query back to verify it was accepted and stored
409 let filter = Filter::new()
410 .kind(Kind::Custom(30618))
411 .author(client.public_key())
412 .identifier(&repo_id);
413
414 let events = client.query(filter).await
415 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
416
417 // Verify we got the event back
418 if events.is_empty() {
419 return Err(format!(
420 "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}",
421 event_id, repo_id
422 ));
423 }
424
425 Ok(())
426 })
427 .await
428 }
429
430 // ============================================================
431 // Helper Functions (6 total)
432 // ============================================================
433
434 /// Extract the `d` tag value from an event
435 fn extract_d_tag(event: &Event) -> Option<String> {
436 for tag in event.tags.iter() {
437 let tag_vec = tag.clone().to_vec();
438 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
439 return Some(tag_vec[1].to_string());
440 }
441 }
442 None
443 }
444
445 /// Create a basic repository announcement (kind 30617)
446 /// Uses the client's create_repo_announcement helper which includes required clone and relays tags
447 async fn create_test_repo(client: &AuditClient, repo_id: &str) -> Result<Event, String> {
448 client.create_repo_announcement(repo_id)
449 .await
450 .map_err(|e| e.to_string())
451 }
452
453 /// Create an issue (kind 1621) that references a repository
454 /// Uses AuditClient::create_issue helper method
455 async fn create_issue_for_repo(
456 client: &AuditClient,
457 repo_event: &Event,
458 issue_title: &str,
459 ) -> Result<Event, String> {
460 client.create_issue(repo_event, issue_title, "issue content", vec![])
461 .map_err(|e| e.to_string())
462 }
463
464 /// Create a NIP-22 comment (kind 1111) for an event
465 /// Uses AuditClient::create_comment helper method
466 async fn create_comment_for_event(
467 client: &AuditClient,
468 event: &Event,
469 content: &str,
470 ) -> Result<Event, String> {
471 client.create_comment(event, content, vec![])
472 .map_err(|e| e.to_string())
473 }
474
475 /// Send event and verify it was accepted (stored by relay)
476 async fn send_and_verify_accepted(
477 client: &AuditClient,
478 event: Event,
479 description: &str,
480 ) -> Result<(), String> {
481 let event_id = event.id;
482
483 client.send_event(event).await
484 .map_err(|e| e.to_string())?;
485
486 tokio::time::sleep(Duration::from_millis(100)).await;
487
488 let filter = Filter::new().id(event_id);
489 let events = client.query(filter).await
490 .map_err(|e| e.to_string())?;
491
492 if events.is_empty() {
493 return Err(format!("Event should be accepted: {}", description));
494 }
495
496 Ok(())
497 }
498
499 /// Send event and verify it was rejected (NOT stored by relay)
500 async fn send_and_verify_rejected(
501 client: &AuditClient,
502 event: Event,
503 description: &str,
504 ) -> Result<(), String> {
505 let event_id = event.id;
506
507 client.send_event(event).await
508 .map_err(|e| e.to_string())?;
509
510 tokio::time::sleep(Duration::from_millis(100)).await;
511
512 let filter = Filter::new().id(event_id);
513 let events = client.query(filter).await
514 .map_err(|e| e.to_string())?;
515
516 if !events.is_empty() {
517 return Err(format!("Event should be rejected: {}", description));
518 }
519
520 Ok(())
521 }
522
523 // ============================================================
524 // Group 1: Accept Events Tagging Accepted Repositories (3 tests)
525 // ============================================================
526
527 /// Test 1.1: Issue referencing repo via `a` tag should be accepted
528 async fn test_accept_issue_via_a_tag(client: &AuditClient) -> TestResult {
529 TestResult::new(
530 "accept_issue_via_a_tag",
531 "GRASP-01:event-acceptance:1.1",
532 "Accept issue referencing repo via 'a' tag",
533 )
534 .run(|| async {
535 // 1. Create and send repo announcement
536 let repo = Self::create_test_repo(client, "test-repo-1").await?;
537 Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?;
538
539 // 2. Create issue that references the repo (uses create_issue_for_repo helper)
540 let issue = Self::create_issue_for_repo(client, &repo, "Test Issue 1").await?;
541
542 // 3. Send issue and verify it's accepted
543 Self::send_and_verify_accepted(client, issue, "issue referencing repo via 'a' tag").await?;
544
545 Ok(())
546 })
547 .await
548 }
549
550 /// Test 1.2: NIP-22 comment with root `A` tag referencing repo should be accepted
551 async fn test_accept_comment_via_A_tag(client: &AuditClient) -> TestResult {
552 TestResult::new(
553 "accept_comment_via_A_tag",
554 "GRASP-01:event-acceptance:1.2",
555 "Accept NIP-22 comment with root 'A' tag referencing repo",
556 )
557 .run(|| async {
558 // 1. Create and send repo announcement
559 let repo = Self::create_test_repo(client, "test-repo-2").await?;
560 Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?;
561
562 // 2. Extract repo_id and create `A` tag manually
563 let repo_id = Self::extract_d_tag(&repo)
564 .ok_or("Failed to extract repo_id from repo event")?;
565 let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
566
567 // 3. Create comment with `A` tag (root reference to repo)
568 let tags = vec![
569 Tag::custom(TagKind::custom("A"), vec![a_tag_value.clone(), "".to_string(), "root".to_string()]),
570 Tag::custom(TagKind::custom("K"), vec!["30617".to_string()]),
571 Tag::public_key(repo.pubkey),
572 ];
573
574 let comment = client
575 .event_builder(Kind::Custom(1111), "Comment on repo")
576 .tags(tags)
577 .build(client.keys())
578 .map_err(|e| format!("Failed to build comment: {}", e))?;
579
580 // 4. Send comment and verify it's accepted
581 Self::send_and_verify_accepted(client, comment, "comment with 'A' tag to repo").await?;
582
583 Ok(())
584 })
585 .await
586 }
587
588 /// Test 1.3: Kind 1 text note quoting repo via `q` tag should be accepted
589 async fn test_accept_kind1_via_q_tag(client: &AuditClient) -> TestResult {
590 TestResult::new(
591 "accept_kind1_via_q_tag",
592 "GRASP-01:event-acceptance:1.3",
593 "Accept kind 1 note quoting repo via 'q' tag",
594 )
595 .run(|| async {
596 // 1. Create and send repo announcement
597 let repo = Self::create_test_repo(client, "test-repo-3").await?;
598 Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?;
599
600 // 2. Extract repo_id and create `q` tag
601 let repo_id = Self::extract_d_tag(&repo)
602 .ok_or("Failed to extract repo_id from repo event")?;
603 let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
604
605 // 3. Create kind 1 note with `q` tag (quote reference to repo)
606 let tags = vec![
607 Tag::custom(TagKind::custom("q"), vec![a_tag_value]),
608 ];
609
610 let note = client
611 .event_builder(Kind::TextNote, "Mentioning this repo")
612 .tags(tags)
613 .build(client.keys())
614 .map_err(|e| format!("Failed to build note: {}", e))?;
615
616 // 4. Send note and verify it's accepted
617 Self::send_and_verify_accepted(client, note, "kind 1 with 'q' tag to repo").await?;
618
619 Ok(())
620 })
621 .await
622 }
623
624 // ============================================================
625 // Group 2: Accept Events Tagging Accepted Events (3 tests)
626 // ============================================================
627
628 /// Test 2.1: Issue quoting another accepted issue should be accepted (transitive)
629 async fn test_accept_issue_quoting_issue_via_q(client: &AuditClient) -> TestResult {
630 TestResult::new(
631 "accept_issue_quoting_issue_via_q",
632 "GRASP-01:event-acceptance:2.1",
633 "Accept issue quoting accepted issue (transitive)",
634 )
635 .run(|| async {
636
637 // 1. Create and send Repo A
638 let repo_a = Self::create_test_repo(client, "repo-a").await?;
639 Self::send_and_verify_accepted(client, repo_a.clone(), "repo A").await?;
640
641 // 2. Create and send Issue A (references repo A, so it's accepted)
642 let issue_a = Self::create_issue_for_repo(client, &repo_a, "Issue A").await?;
643 Self::send_and_verify_accepted(client, issue_a.clone(), "issue A").await?;
644
645 // 3. Create Repo B but DON'T send it (unaccepted) - just for creating Issue B
646 let repo_b = Self::create_test_repo(client, "repo-b").await?;
647
648 // 4. Create Issue B that:
649 // - References unaccepted Repo B (would normally be rejected)
650 // - BUT also quotes accepted Issue A via 'q' tag (should make it accepted)
651 let additional_tags = vec![
652 // Quote to accepted Issue A (this makes it transitive)
653 Tag::custom(TagKind::custom("q"), vec![issue_a.id.to_hex()]),
654 ];
655
656 let issue_b = client
657 .create_issue(&repo_b, "Issue B", "issue content", additional_tags)
658 .map_err(|e| format!("Failed to build issue B: {}", e))?;
659
660 // 5. Send Issue B and verify it's ACCEPTED (via transitive quote to Issue A)
661 Self::send_and_verify_accepted(client, issue_b, "issue B quoting accepted issue A").await?;
662
663 Ok(())
664 })
665 .await
666 }
667
668 /// Test 2.2: NIP-22 comment with root 'E' tag to accepted issue should be accepted
669 async fn test_accept_comment_via_E_tag(client: &AuditClient) -> TestResult {
670 TestResult::new(
671 "accept_comment_via_E_tag",
672 "GRASP-01:event-acceptance:2.2",
673 "Accept NIP-22 comment with root 'E' tag to accepted issue",
674 )
675 .run(|| async {
676
677 // 1. Create and send repo
678 let repo = Self::create_test_repo(client, "repo-comment").await?;
679 Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
680
681 // 2. Create and send issue (references repo, so it's accepted)
682 let issue = Self::create_issue_for_repo(client, &repo, "Issue for comment").await?;
683 Self::send_and_verify_accepted(client, issue.clone(), "issue").await?;
684
685 // 3. Create comment using the helper (which adds NIP-22 tags including 'E')
686 let comment = Self::create_comment_for_event(client, &issue, "Comment content").await?;
687
688 // 4. Send comment and verify it's accepted (via E tag to accepted issue)
689 Self::send_and_verify_accepted(client, comment, "comment with E tag to accepted issue").await?;
690
691 Ok(())
692 })
693 .await
694 }
695
696 /// Test 2.3: Kind 1 note with 'e' tag reply to accepted kind 1 should be accepted
697 async fn test_accept_kind1_via_e_tag(client: &AuditClient) -> TestResult {
698 TestResult::new(
699 "accept_kind1_via_e_tag",
700 "GRASP-01:event-acceptance:2.3",
701 "Accept kind 1 reply via 'e' tag to accepted kind 1",
702 )
703 .run(|| async {
704
705 // 1. Create and send repo
706 let repo = Self::create_test_repo(client, "repo-notes").await?;
707 Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
708
709 // 2. Create Kind 1 A that quotes the repo (makes it accepted)
710 let repo_id = Self::extract_d_tag(&repo)
711 .ok_or("Failed to extract repo_id")?;
712 let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
713
714 let kind1_a = client
715 .event_builder(Kind::TextNote, "Note A about repo")
716 .tags(vec![Tag::custom(TagKind::custom("q"), vec![a_tag_value])])
717 .build(client.keys())
718 .map_err(|e| format!("Failed to build kind1 A: {}", e))?;
719
720 Self::send_and_verify_accepted(client, kind1_a.clone(), "kind 1 A quoting repo").await?;
721
722 // 3. Create Kind 1 B that replies to Kind 1 A via 'e' tag
723 let kind1_b = client
724 .event_builder(Kind::TextNote, "Reply to Note A")
725 .tags(vec![Tag::event(kind1_a.id)])
726 .build(client.keys())
727 .map_err(|e| format!("Failed to build kind1 B: {}", e))?;
728
729 // 4. Send Kind 1 B and verify it's accepted (via 'e' tag to accepted kind 1 A)
730 Self::send_and_verify_accepted(client, kind1_b, "kind 1 B replying to accepted kind 1 A").await?;
731
732 Ok(())
733 })
734 .await
735 }
736
737 // ============================================================
738 // Group 3: Accept Events Tagged BY Accepted Events (3 tests)
739 // ============================================================
740
741 /// Test 3.1: Kind 1 note should be accepted when referenced by an accepted issue (forward ref)
742 async fn test_accept_kind1_referenced_in_issue(client: &AuditClient) -> TestResult {
743 TestResult::new(
744 "accept_kind1_referenced_in_issue",
745 "GRASP-01:event-acceptance:3.1",
746 "Accept kind 1 referenced in accepted issue (forward ref)",
747 )
748 .run(|| async {
749
750 // 1. Create and send repo (this establishes the accepted context)
751 let repo = Self::create_test_repo(client, "repo-fwd-1").await?;
752 Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
753
754 // 2. Create Kind 1 note locally but DON'T send it yet
755 let kind1_note = client
756 .event_builder(Kind::TextNote, "Note to be referenced")
757 .build(client.keys())
758 .map_err(|e| format!("Failed to build kind1: {}", e))?;
759
760 // 3. Create and send issue that QUOTES the unsent Kind 1 note
761 let issue_tags = vec![
762 // Reference to accepted repo
763 Tag::custom(TagKind::custom("a"), vec![
764 format!("30617:{}:{}", repo.pubkey, Self::extract_d_tag(&repo).unwrap())
765 ]),
766 Tag::custom(TagKind::custom("subject"), vec!["Issue referencing kind1".to_string()]),
767 // Quote the Kind 1 that hasn't been sent yet
768 Tag::custom(TagKind::custom("q"), vec![kind1_note.id.to_hex()]),
769 ];
770
771 let issue = client
772 .event_builder(Kind::Custom(1621), "issue content")
773 .tags(issue_tags)
774 .build(client.keys())
775 .map_err(|e| format!("Failed to build issue: {}", e))?;
776
777 Self::send_and_verify_accepted(client, issue, "issue quoting unsent kind1").await?;
778
779 // 4. NOW send the Kind 1 note - should be accepted because accepted issue quotes it
780 Self::send_and_verify_accepted(client, kind1_note, "kind1 note referenced by accepted issue").await?;
781
782 Ok(())
783 })
784 .await
785 }
786
787 /// Test 3.2: Comment should be accepted when referenced by another accepted comment (forward ref)
788 async fn test_accept_comment_referenced_in_comment(client: &AuditClient) -> TestResult {
789 TestResult::new(
790 "accept_comment_referenced_in_comment",
791 "GRASP-01:event-acceptance:3.2",
792 "Accept comment referenced in another accepted comment (forward ref)",
793 )
794 .run(|| async {
795
796 // 1. Create and send repo
797 let repo = Self::create_test_repo(client, "repo-fwd-2").await?;
798 Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
799
800 // 2. Create and send issue (references repo, so it's accepted)
801 let issue = Self::create_issue_for_repo(client, &repo, "Issue for comments").await?;
802 Self::send_and_verify_accepted(client, issue.clone(), "issue").await?;
803
804 // 3. Create Comment A locally but DON'T send it yet
805 let comment_a = Self::create_comment_for_event(client, &issue, "Comment A").await?;
806
807 // 4. Create and send Comment B that quotes Comment A (which hasn't been sent)
808 let comment_b_tags = vec![
809 // NIP-22 tags for the original issue
810 Tag::custom(TagKind::custom("E"), vec![issue.id.to_hex(), "".to_string(), "root".to_string()]),
811 Tag::event(issue.id),
812 Tag::custom(TagKind::custom("K"), vec![issue.kind.as_u16().to_string()]),
813 Tag::public_key(issue.pubkey),
814 // Quote Comment A which hasn't been sent yet
815 Tag::custom(TagKind::custom("q"), vec![comment_a.id.to_hex()]),
816 ];
817
818 let comment_b = client
819 .event_builder(Kind::Custom(1111), "Comment B quoting Comment A")
820 .tags(comment_b_tags)
821 .build(client.keys())
822 .map_err(|e| format!("Failed to build comment B: {}", e))?;
823
824 Self::send_and_verify_accepted(client, comment_b, "comment B quoting unsent comment A").await?;
825
826 // 5. NOW send Comment A - should be accepted because accepted Comment B quotes it
827 Self::send_and_verify_accepted(client, comment_a, "comment A referenced by accepted comment B").await?;
828
829 Ok(())
830 })
831 .await
832 }
833
834 /// Test 3.3: Kind 1 note should be accepted when referenced by another accepted kind 1 (forward ref)
835 async fn test_accept_kind1_referenced_in_kind1(client: &AuditClient) -> TestResult {
836 TestResult::new(
837 "accept_kind1_referenced_in_kind1",
838 "GRASP-01:event-acceptance:3.3",
839 "Accept kind 1 referenced in another accepted kind 1 (forward ref)",
840 )
841 .run(|| async {
842 // 1. Create and send repo
843 let repo = Self::create_test_repo(client, "repo-fwd-3").await?;
844 Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
845
846 // 2. Create Kind 1 A locally but DON'T send it yet
847 let kind1_a = client
848 .event_builder(Kind::TextNote, "Note A to be referenced")
849 .build(client.keys())
850 .map_err(|e| format!("Failed to build kind1 A: {}", e))?;
851
852 // 3. Create and send Kind 1 B that:
853 // - Quotes the repo (makes it accepted)
854 // - Mentions Kind 1 A via 'e' tag (which hasn't been sent yet)
855 let repo_id = Self::extract_d_tag(&repo)
856 .ok_or("Failed to extract repo_id")?;
857 let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
858
859 let kind1_b = client
860 .event_builder(Kind::TextNote, "Note B mentioning Note A")
861 .tags(vec![
862 Tag::custom(TagKind::custom("q"), vec![a_tag_value]), // Quote repo (accepted)
863 Tag::event(kind1_a.id), // Mention unsent Kind 1 A
864 ])
865 .build(client.keys())
866 .map_err(|e| format!("Failed to build kind1 B: {}", e))?;
867
868 Self::send_and_verify_accepted(client, kind1_b, "kind1 B mentioning unsent kind1 A").await?;
869
870 // 4. NOW send Kind 1 A - should be accepted because accepted Kind 1 B mentions it
871 Self::send_and_verify_accepted(client, kind1_a, "kind1 A referenced by accepted kind1 B").await?;
872
873 Ok(())
874 })
875 .await
876 }
877
878 // ============================================================
879 // Group 4: Reject Unrelated Events (3 tests)
880 // ============================================================
881
882 /// Test 4.1: Issue referencing unaccepted repo should be rejected
883 async fn test_reject_orphan_issue(client: &AuditClient) -> TestResult {
884 TestResult::new(
885 "reject_orphan_issue",
886 "GRASP-01:event-acceptance:4.1",
887 "Reject issue referencing unaccepted repo",
888 )
889 .run(|| async {
890 // 1. Create a repo but DON'T send it (so it's unaccepted)
891 let unaccepted_repo = Self::create_test_repo(client, "unaccepted-repo-1").await?;
892
893 // 2. Create issue that references the unaccepted repo
894 let orphan_issue = Self::create_issue_for_repo(client, &unaccepted_repo, "Orphan Issue").await?;
895
896 // 3. Send issue and verify it's REJECTED
897 Self::send_and_verify_rejected(client, orphan_issue, "issue referencing unaccepted repo").await?;
898
899 Ok(())
900 })
901 .await
902 }
903
904 /// Test 4.2: Generic kind 1 note with no repo references should be rejected
905 async fn test_reject_orphan_kind1(client: &AuditClient) -> TestResult {
906 TestResult::new(
907 "reject_orphan_kind1",
908 "GRASP-01:event-acceptance:4.2",
909 "Reject kind 1 with no repo references",
910 )
911 .run(|| async {
912 // 1. Create a kind 1 note with no tags (no repo references)
913 let orphan_note = client
914 .event_builder(Kind::TextNote, "Just a random note")
915 .build(client.keys())
916 .map_err(|e| format!("Failed to build note: {}", e))?;
917
918 // 2. Send note and verify it's REJECTED
919 Self::send_and_verify_rejected(client, orphan_note, "kind 1 with no repo references").await?;
920
921 Ok(())
922 })
923 .await
924 }
925
926 /// Test 4.3: Comment quoting unaccepted repo should be rejected
927 async fn test_reject_comment_quoting_other_repo(client: &AuditClient) -> TestResult {
928 TestResult::new(
929 "reject_comment_quoting_other_repo",
930 "GRASP-01:event-acceptance:4.3",
931 "Reject comment quoting unaccepted repo",
932 )
933 .run(|| async {
934 // 1. Create and send Repo A (this one IS accepted)
935 let repo_a = Self::create_test_repo(client, "accepted-repo-a").await?;
936 Self::send_and_verify_accepted(client, repo_a.clone(), "repo A").await?;
937
938 // 2. Create Repo B but DON'T send it (unaccepted)
939 let repo_b = Self::create_test_repo(client, "unaccepted-repo-b").await?;
940
941 // 3. Extract repo_b info and create comment that quotes repo B (not repo A)
942 let repo_b_id = Self::extract_d_tag(&repo_b)
943 .ok_or("Failed to extract repo_b id")?;
944 let repo_b_a_tag = format!("30617:{}:{}", repo_b.pubkey, repo_b_id);
945
946 // 4. Create comment that references ONLY repo B (unaccepted)
947 let tags = vec![
948 Tag::custom(TagKind::custom("A"), vec![repo_b_a_tag, "".to_string(), "root".to_string()]),
949 Tag::custom(TagKind::custom("K"), vec!["30617".to_string()]),
950 Tag::public_key(repo_b.pubkey),
951 ];
952
953 let comment = client
954 .event_builder(Kind::Custom(1111), "Comment on unaccepted repo")
955 .tags(tags)
956 .build(client.keys())
957 .map_err(|e| format!("Failed to build comment: {}", e))?;
958
959 // 5. Send comment and verify it's REJECTED (only references unaccepted repo B)
960 Self::send_and_verify_rejected(client, comment, "comment quoting only unaccepted repo").await?;
961
962 Ok(())
963 })
964 .await
965 }
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use crate::AuditConfig;
972
973 #[tokio::test]
974 #[ignore] // Requires running relay
975 async fn test_grasp01_event_acceptance_policy_against_relay() {
976 // Read relay URL from environment variable - must be supplied
977 let relay_url = std::env::var("RELAY_URL")
978 .expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081");
979
980 let config = AuditConfig::ci();
981 let client = AuditClient::new(&relay_url, config)
982 .await
983 .expect(&format!(
984 "Failed to connect to relay at {}. Ensure relay is running and accessible. \
985 Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest",
986 relay_url
987 ));
988
989 let results = EventAcceptancePolicyTests::run_all(&client).await;
990 results.print_report();
991
992 // Don't assert all passed yet - some tests may be failing
993 // Future: assert!(results.all_passed(), "Some GRASP-01 event acceptance tests failed");
994 }
995} \ No newline at end of file
diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs
new file mode 100644
index 0000000..4f4583e
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/mod.rs
@@ -0,0 +1,9 @@
1//! GRASP-01 specification tests
2
3pub mod event_acceptance_policy;
4pub mod nip01_smoke;
5pub mod nip11_document;
6
7pub use event_acceptance_policy::EventAcceptancePolicyTests;
8pub use nip01_smoke::Nip01SmokeTests;
9pub use nip11_document::Nip11DocumentTests; \ No newline at end of file
diff --git a/grasp-audit/src/specs/nip01_smoke.rs b/grasp-audit/src/specs/grasp01/nip01_smoke.rs
index 9ed0f56..9ed0f56 100644
--- a/grasp-audit/src/specs/nip01_smoke.rs
+++ b/grasp-audit/src/specs/grasp01/nip01_smoke.rs
diff --git a/grasp-audit/src/specs/grasp01/nip11_document.rs b/grasp-audit/src/specs/grasp01/nip11_document.rs
new file mode 100644
index 0000000..3f9c04a
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/nip11_document.rs
@@ -0,0 +1,165 @@
1//! GRASP-01 NIP-11 Document
2//!
3//! Tests for GRASP-01 NIP-11 relay information document requirements (lines 11-14 of ../grasp/01.md)
4//!
5//! These tests validate that a GRASP-01 compliant relay:
6//! - Serves a valid NIP-11 relay information document
7//! - Includes supported_grasps field listing supported GRASPs
8//! - Includes repo_acceptance_criteria field describing acceptance policy
9//! - Handles curation field correctly (present if curated, absent otherwise)
10
11use crate::{AuditClient, AuditResult, TestResult};
12use nostr_sdk::prelude::*;
13
14pub struct Nip11DocumentTests;
15
16impl Nip11DocumentTests {
17 /// Run all NIP-11 document tests
18 pub async fn run_all(client: &AuditClient) -> AuditResult {
19 let mut results = AuditResult::new("GRASP-01 NIP-11 Document Tests");
20
21 // NIP-11 relay information tests
22 results.add(Self::test_nip11_document_exists(client).await);
23 results.add(Self::test_nip11_supported_grasps_field(client).await);
24 results.add(Self::test_nip11_repo_acceptance_criteria_field(client).await);
25 results.add(Self::test_nip11_curation_field(client).await);
26
27 results
28 }
29
30 // =========================================================================
31 // NIP-11 Relay Information Tests
32 // =========================================================================
33
34 /// Test: Serve NIP-11 document
35 ///
36 /// Spec: Line 11 of ../grasp/01.md
37 /// Requirement: MUST serve NIP-11 document
38 async fn test_nip11_document_exists(client: &AuditClient) -> TestResult {
39 TestResult::new(
40 "nip11_document_exists",
41 "GRASP-01:nostr-relay:11",
42 "Serve NIP-11 relay information document",
43 )
44 .run(|| async {
45 // TODO: Implementation
46 // 1. Extract HTTP(S) URL from client's WebSocket URL
47 // - ws://localhost:8081 -> http://localhost:8081
48 // - wss://relay.example.com -> https://relay.example.com
49 // 2. HTTP GET to base URL with header:
50 // - Accept: application/nostr+json
51 // 3. Verify 200 OK response
52 // 4. Verify response is valid JSON
53 // 5. Parse as NIP-11 document
54 // 6. Verify has required fields (name, description, etc.)
55
56 Err("Not implemented yet".to_string())
57 })
58 .await
59 }
60
61 /// Test: NIP-11 includes supported_grasps field
62 ///
63 /// Spec: Line 12 of ../grasp/01.md
64 /// Requirement: MUST list supported GRASPs as string array
65 async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult {
66 TestResult::new(
67 "nip11_supported_grasps_field",
68 "GRASP-01:nostr-relay:12",
69 "NIP-11 document includes supported_grasps field with GRASP-01",
70 )
71 .run(|| async {
72 // TODO: Implementation
73 // 1. Fetch NIP-11 document (same as above)
74 // 2. Verify `supported_grasps` field exists
75 // 3. Verify it's a JSON array of strings
76 // 4. Verify array includes "GRASP-01"
77 // 5. Verify format: each entry matches pattern "GRASP-\d{2}"
78 // 6. Document other GRASPs found (for info)
79
80 Err("Not implemented yet".to_string())
81 })
82 .await
83 }
84
85 /// Test: NIP-11 includes repo_acceptance_criteria field
86 ///
87 /// Spec: Line 13 of ../grasp/01.md
88 /// Requirement: MUST list repository acceptance criteria
89 async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult {
90 TestResult::new(
91 "nip11_repo_acceptance_criteria_field",
92 "GRASP-01:nostr-relay:13",
93 "NIP-11 document includes repo_acceptance_criteria field",
94 )
95 .run(|| async {
96 // TODO: Implementation
97 // 1. Fetch NIP-11 document
98 // 2. Verify `repo_acceptance_criteria` field exists
99 // 3. Verify it's a string (human-readable)
100 // 4. Verify non-empty
101 // 5. Document the criteria (for info)
102 // Examples: "Must list this relay in clone and relays tags"
103 // "Pre-payment required via Lightning invoice"
104
105 Err("Not implemented yet".to_string())
106 })
107 .await
108 }
109
110 /// Test: NIP-11 curation field handling
111 ///
112 /// Spec: Line 14 of ../grasp/01.md
113 /// Requirement: MUST include curation if curated, omit otherwise
114 async fn test_nip11_curation_field(client: &AuditClient) -> TestResult {
115 TestResult::new(
116 "nip11_curation_field",
117 "GRASP-01:nostr-relay:14",
118 "NIP-11 curation field present if curated, absent otherwise",
119 )
120 .run(|| async {
121 // TODO: Implementation
122 // 1. Fetch NIP-11 document
123 // 2. Check if `curation` field exists
124 // 3. If present:
125 // - Verify it's a non-empty string
126 // - Document the curation policy
127 // 4. If absent:
128 // - Document that no curation beyond SPAM prevention
129 // 5. Both cases are valid per spec
130
131 Err("Not implemented yet".to_string())
132 })
133 .await
134 }
135
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::AuditConfig;
142
143#[tokio::test]
144#[ignore] // Requires running relay
145async fn test_grasp01_nip11_document_against_relay() {
146 // Read relay URL from environment variable - must be supplied
147 let relay_url = std::env::var("RELAY_URL")
148 .expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081");
149
150 let config = AuditConfig::ci();
151 let client = AuditClient::new(&relay_url, config)
152 .await
153 .expect(&format!(
154 "Failed to connect to relay at {}. Ensure relay is running and accessible. \
155 Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest",
156 relay_url
157 ));
158
159 let results = Nip11DocumentTests::run_all(&client).await;
160 results.print_report();
161
162 // Don't assert all passed yet - tests not implemented
163 // assert!(results.all_passed(), "Some GRASP-01 NIP-11 document tests failed");
164 }
165} \ No newline at end of file
diff --git a/grasp-audit/src/specs/grasp01_nostr_relay.rs b/grasp-audit/src/specs/grasp01_nostr_relay.rs
deleted file mode 100644
index 247850b..0000000
--- a/grasp-audit/src/specs/grasp01_nostr_relay.rs
+++ /dev/null
@@ -1,717 +0,0 @@
1//! GRASP-01 Nostr Relay Tests
2//!
3//! Tests for GRASP-01 Nostr relay requirements (lines 1-14 of ../grasp/01.md)
4//!
5//! These tests validate that a GRASP-01 compliant relay:
6//! - Accepts valid NIP-34 repository announcements and state announcements
7//! - Rejects announcements that don't list the service
8//! - Accepts related events (issues, patches, PRs)
9//! - Serves proper NIP-11 relay information document
10
11use crate::{AuditClient, AuditResult, TestResult};
12use nostr_sdk::prelude::*;
13
14pub struct Grasp01NostrRelayTests;
15
16impl Grasp01NostrRelayTests {
17 /// Run all GRASP-01 Nostr relay tests
18 pub async fn run_all(client: &AuditClient) -> AuditResult {
19 let mut results = AuditResult::new("GRASP-01 Nostr Relay Tests");
20
21 // Repository announcement acceptance tests
22 results.add(Self::test_accept_valid_repo_announcement(client).await);
23 results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await);
24 results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await);
25
26 // Repository state announcement tests
27 results.add(Self::test_accept_valid_repo_state_announcement(client).await);
28
29 // Related event acceptance tests
30 results.add(Self::test_accept_event_tagging_repo_announcement(client).await);
31 results.add(Self::test_accept_event_tagged_by_repo(client).await);
32 results.add(Self::test_accept_patch_for_repo(client).await);
33 results.add(Self::test_accept_pull_request_for_repo(client).await);
34 results.add(Self::test_accept_issue_for_repo(client).await);
35 results.add(Self::test_accept_reply_to_issue(client).await);
36
37 // NIP-11 relay information tests
38 results.add(Self::test_nip11_document_exists(client).await);
39 results.add(Self::test_nip11_supported_grasps_field(client).await);
40 results.add(Self::test_nip11_repo_acceptance_criteria_field(client).await);
41 results.add(Self::test_nip11_curation_field(client).await);
42
43 // Policy tests (document behavior)
44 results.add(Self::test_custom_rejection_allowed(client).await);
45 results.add(Self::test_spam_prevention_allowed(client).await);
46
47 results
48 }
49
50 // =========================================================================
51 // Repository Announcement Acceptance Tests
52 // =========================================================================
53
54 /// Test: Accept valid repository announcements
55 ///
56 /// Spec: Lines 3-5 of ../grasp/01.md
57 /// Requirement: MUST accept repo announcements listing service in clone & relays tags
58 async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult {
59 TestResult::new(
60 "accept_valid_repo_announcement",
61 "GRASP-01:nostr-relay:3-5",
62 "Accept valid repository announcements with service in clone and relays tags",
63 )
64 .run(|| async {
65 // Create a NIP-34 repository announcement event
66 let event = client.create_repo_announcement("accept_valid_repo_announcement").await
67 .map_err(|e| format!("Failed to create repository announcement: {}", e))?;
68
69 // Get relay URL for validation
70 let relay_url = client.client().relays().await
71 .keys()
72 .next()
73 .ok_or("No relay connected")?
74 .to_string();
75
76 // Convert WebSocket URL to HTTP URL for validation
77 let http_url = relay_url
78 .replace("ws://", "http://")
79 .replace("wss://", "https://");
80
81 // Extract repo_id from the event's d tag
82 let repo_id = event.tags.iter()
83 .find(|t| t.kind() == TagKind::d())
84 .and_then(|t| t.content())
85 .ok_or("Missing d tag in announcement")?
86 .to_string();
87
88 // Send the event
89 let event_id = client.send_event(event.clone()).await
90 .map_err(|e| format!("Failed to send repository announcement to relay: {}", e))?;
91
92 // Query back to verify it was accepted and stored
93 let filter = Filter::new()
94 .kind(Kind::GitRepoAnnouncement)
95 .author(client.public_key())
96 .identifier(&repo_id);
97
98 let events = client.query(filter).await
99 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
100
101 // Verify we got the event back
102 if events.is_empty() {
103 return Err(format!(
104 "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}",
105 event_id, repo_id
106 ));
107 }
108
109 // Verify it's the same event
110 let stored_event = events.iter()
111 .find(|e| e.id == event_id)
112 .ok_or(format!(
113 "Stored event ID doesn't match sent event. Expected: {}, Got {} events",
114 event_id, events.len()
115 ))?;
116
117 // Verify key tags are present
118 let has_clone_tag = stored_event.tags.iter()
119 .any(|t| {
120 t.kind() == TagKind::Custom("clone".into()) &&
121 t.content().map(|c| c.contains(&http_url)).unwrap_or(false)
122 });
123
124 let has_relays_tag = stored_event.tags.iter()
125 .any(|t| {
126 t.kind() == TagKind::Custom("relays".into()) &&
127 t.content() == Some(&relay_url)
128 });
129
130 if !has_clone_tag {
131 return Err(format!("Stored event missing clone tag with service URL ({})", http_url));
132 }
133
134 if !has_relays_tag {
135 return Err(format!("Stored event missing relays tag with service URL ({})", relay_url));
136 }
137
138 Ok(())
139 })
140 .await
141 }
142
143 /// Test: Reject repo announcements not listing service in clone tag
144 ///
145 /// Spec: Line 5 of ../grasp/01.md
146 /// Requirement: MUST reject announcements not listing service (unless GRASP-05)
147 async fn test_reject_repo_announcement_missing_clone_tag(client: &AuditClient) -> TestResult {
148 TestResult::new(
149 "reject_repo_announcement_missing_clone_tag",
150 "GRASP-01:nostr-relay:5",
151 "Reject repository announcements without service in clone tag",
152 )
153 .run(|| async {
154 // Get relay URL from client
155 let relay_url = client.client().relays().await
156 .keys()
157 .next()
158 .ok_or("No relay connected - client has no active relay connections")?
159 .to_string();
160
161 // Create unique repository identifier
162 let timestamp = Timestamp::now().as_u64();
163 let repo_id = format!("test-repo-no-clone-{}", timestamp);
164
165 // Create repo announcement WITHOUT service in clone tag
166 let event = client.event_builder(Kind::GitRepoAnnouncement, "")
167 .tag(Tag::identifier(&repo_id))
168 .tag(Tag::custom(TagKind::Custom("name".into()), vec!["Test Repo No Clone"]))
169 .tag(Tag::custom(TagKind::Custom("clone".into()), vec!["https://github.com/user/repo.git"])) // NOT this service
170 .tag(Tag::custom(TagKind::Custom("relays".into()), vec![relay_url.clone()])) // Correct relay
171 .build(client.keys())
172 .map_err(|e| format!("Failed to build event: {}", e))?;
173
174 let event_id = event.id;
175
176 // Send event - expect rejection
177 let send_result = client.send_event(event.clone()).await;
178
179 // Query to verify event is NOT stored
180 let filter = Filter::new()
181 .kind(Kind::GitRepoAnnouncement)
182 .author(client.public_key())
183 .identifier(&repo_id);
184
185 let events = client.query(filter).await
186 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
187
188 // Verify event was rejected (not stored)
189 if events.iter().any(|e| e.id == event_id) {
190 return Err(format!(
191 "Relay incorrectly accepted announcement without service in clone tag. \
192 Event ID: {}, Clone URL: https://github.com/user/repo.git (should require {})",
193 event_id, relay_url
194 ));
195 }
196
197 Ok(())
198 })
199 .await
200 }
201
202 /// Test: Reject repo announcements not listing service in relays tag
203 ///
204 /// Spec: Line 5 of ../grasp/01.md
205 /// Requirement: MUST reject announcements not listing service in relays
206 async fn test_reject_repo_announcement_missing_relays_tag(client: &AuditClient) -> TestResult {
207 TestResult::new(
208 "reject_repo_announcement_missing_relays_tag",
209 "GRASP-01:nostr-relay:5",
210 "Reject repository announcements without service in relays tag",
211 )
212 .run(|| async {
213 // Get relay URL from client
214 let relay_url = client.client().relays().await
215 .keys()
216 .next()
217 .ok_or("No relay connected - client has no active relay connections")?
218 .to_string();
219
220 // Convert WebSocket URL to HTTP URL for clone tag
221 let http_url = relay_url
222 .replace("ws://", "http://")
223 .replace("wss://", "https://");
224
225 // Create unique repository identifier
226 let timestamp = Timestamp::now().as_u64();
227 let repo_id = format!("test-repo-no-relays-{}", timestamp);
228
229 // Create repo announcement WITHOUT service in relays tag
230 let event = client.event_builder(Kind::GitRepoAnnouncement, "")
231 .tag(Tag::identifier(&repo_id))
232 .tag(Tag::custom(TagKind::custom("name"), vec!["Test Repo No Relays"]))
233 .tag(Tag::custom(TagKind::custom("clone"), vec![format!("{}/{}/test-repo.git", http_url, client.public_key())])) // Correct clone
234 .tag(Tag::custom(TagKind::custom("relays"), vec!["wss://relay.damus.io"])) // NOT this service
235 .build(client.keys())
236 .map_err(|e| format!("Failed to build event: {}", e))?;
237
238 let event_id = event.id;
239
240 // Send event - expect rejection
241 let _send_result = client.send_event(event.clone()).await;
242
243 // Query to verify event is NOT stored
244 let filter = Filter::new()
245 .kind(Kind::GitRepoAnnouncement)
246 .author(client.public_key())
247 .identifier(&repo_id);
248
249 let events = client.query(filter).await
250 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
251
252 // Verify event was rejected (not stored)
253 if events.iter().any(|e| e.id == event_id) {
254 return Err(format!(
255 "Relay incorrectly accepted announcement without service in relays tag. \
256 Event ID: {}, Relays URL: wss://relay.damus.io (should require {})",
257 event_id, relay_url
258 ));
259 }
260
261 Ok(())
262 })
263 .await
264 }
265
266 // =========================================================================
267 // Repository State Announcement Tests
268 // =========================================================================
269
270 /// Test: Accept valid repository state announcements
271 ///
272 /// Spec: Lines 6-7 of ../grasp/01.md
273 /// Requirement: MUST accept repo state announcements with d, maintainers, and r tags
274 async fn test_accept_valid_repo_state_announcement(client: &AuditClient) -> TestResult {
275 TestResult::new(
276 "accept_valid_repo_state_announcement",
277 "GRASP-01:nostr-relay:6-7",
278 "Accept valid state announcements after repo announcement accepted",
279 )
280 .run(|| async {
281 // First, create a repository announcement (kind 30617) by the same author
282 let test_name = format!("test-repo-multi-refs-{}", Timestamp::now().as_u64());
283 let repo_event = client.create_repo_announcement(&test_name).await
284 .map_err(|e| format!("Failed to create repository announcement: {}", e))?;
285
286 // Extract repo_id from the repository announcement
287 let repo_id = repo_event.tags.iter()
288 .find(|t| t.kind() == TagKind::d())
289 .and_then(|t| t.content())
290 .ok_or("Missing d tag in repository announcement")?
291 .to_string();
292
293 // Get maintainer npub
294 let npub = client.public_key().to_bech32()
295 .map_err(|e| format!("Failed to convert public key to bech32: {}", e))?;
296
297 // Create kind 30618 repository state announcement with multiple refs
298 // Format: ["r", "refs/heads/main", "<commit-id>"]
299 let event = client.event_builder(Kind::Custom(30618), "")
300 .tag(Tag::identifier(&repo_id))
301 .tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![
302 "abc123def456789012345678901234567890abcd"
303 ]))
304 .tag(Tag::custom(TagKind::custom("refs/heads/develop"), vec![
305 "def456789012345678901234567890abcdef123"
306 ]))
307 .tag(Tag::custom(TagKind::custom("refs/tags/v1.0.0"), vec![
308 "123456789012345678901234567890abcdef456"
309 ]))
310 .tag(Tag::custom(TagKind::custom("HEAD"), vec![
311 "ref: refs/heads/main"
312 ]))
313 .build(client.keys())
314 .map_err(|e| format!("Failed to build state announcement: {}", e))?;
315
316 let event_id = event.id;
317
318 // Send the repo announcement event
319 client.send_event(repo_event.clone()).await
320 .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?;
321
322 // Send the state event
323 client.send_event(event.clone()).await
324 .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?;
325
326 // Query back to verify it was accepted and stored
327 let filter = Filter::new()
328 .kind(Kind::Custom(30618))
329 .author(client.public_key())
330 .identifier(&repo_id);
331
332 let events = client.query(filter).await
333 .map_err(|e| format!("Failed to query events from relay: {}", e))?;
334
335 // Verify we got the event back
336 if events.is_empty() {
337 return Err(format!(
338 "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}",
339 event_id, repo_id
340 ));
341 }
342
343 Ok(())
344 })
345 .await
346 }
347
348
349 // =========================================================================
350 // Related Event Acceptance Tests
351 // =========================================================================
352
353 /// Test: Accept events tagging accepted repo announcements
354 ///
355 /// Spec: Lines 7-9 of ../grasp/01.md
356 /// Requirement: MUST accept events that tag accepted repo announcements
357 async fn test_accept_event_tagging_repo_announcement(client: &AuditClient) -> TestResult {
358 TestResult::new(
359 "accept_event_tagging_repo_announcement",
360 "GRASP-01:nostr-relay:7-9",
361 "Accept events that tag accepted repository announcements",
362 )
363 .run(|| async {
364 // TODO: Implementation
365 // 1. Create and send kind 30617 repo announcement
366 // 2. Create kind 1621 (issue) event with:
367 // - a tag: "30617:{pubkey}:{d-tag}"
368 // - p tag: repo owner pubkey
369 // - subject tag: "Test Issue"
370 // - content: "This is a test issue"
371 // 3. Send issue event
372 // 4. Verify acceptance
373 // 5. Query to confirm issue is stored
374
375 Err("Not implemented yet".to_string())
376 })
377 .await
378 }
379
380 /// Test: Accept events tagged by repo announcements
381 ///
382 /// Spec: Lines 7-9 of ../grasp/01.md
383 /// Requirement: MUST accept events tagged by accepted announcements
384 async fn test_accept_event_tagged_by_repo(client: &AuditClient) -> TestResult {
385 TestResult::new(
386 "accept_event_tagged_by_repo",
387 "GRASP-01:nostr-relay:7-9",
388 "Accept events that are tagged by accepted repository announcements",
389 )
390 .run(|| async {
391 // TODO: Implementation
392 // 1. Create kind 1 note event (regular note)
393 // 2. Send the note
394 // 3. Create kind 30617 repo announcement that tags the note
395 // - Include e tag pointing to note event ID
396 // 4. Send repo announcement
397 // 5. Verify both events are stored
398 // 6. This tests that related events are retained
399
400 Err("Not implemented yet".to_string())
401 })
402 .await
403 }
404
405 /// Test: Accept patches (kind 1617) for accepted repos
406 ///
407 /// Spec: Lines 8-9 of ../grasp/01.md
408 /// Requirement: MUST accept patches for accepted repos
409 async fn test_accept_patch_for_repo(client: &AuditClient) -> TestResult {
410 TestResult::new(
411 "accept_patch_for_repo",
412 "GRASP-01:nostr-relay:8-9",
413 "Accept patch events (kind 1617) for accepted repositories",
414 )
415 .run(|| async {
416 // TODO: Implementation
417 // 1. Create and send kind 30617 repo announcement
418 // 2. Create kind 1617 patch event with:
419 // - a tag: "30617:{pubkey}:{d-tag}"
420 // - p tag: repo owner
421 // - r tag: earliest-unique-commit-id
422 // - t tag: "root" (first patch in series)
423 // - content: actual git format-patch output
424 // 3. Send patch event
425 // 4. Verify acceptance
426 // 5. Query to confirm patch is stored
427
428 Err("Not implemented yet".to_string())
429 })
430 .await
431 }
432
433 /// Test: Accept pull requests (kind 1618) for accepted repos
434 ///
435 /// Spec: Lines 8-9 of ../grasp/01.md
436 /// Requirement: MUST accept PRs for accepted repos
437 async fn test_accept_pull_request_for_repo(client: &AuditClient) -> TestResult {
438 TestResult::new(
439 "accept_pull_request_for_repo",
440 "GRASP-01:nostr-relay:8-9",
441 "Accept pull request events (kind 1618) for accepted repositories",
442 )
443 .run(|| async {
444 // TODO: Implementation
445 // 1. Create and send kind 30617 repo announcement
446 // 2. Create kind 1618 PR event with:
447 // - a tag: "30617:{pubkey}:{d-tag}"
448 // - p tag: repo owner
449 // - r tag: earliest-unique-commit-id
450 // - subject tag: "Add feature X"
451 // - c tag: commit SHA of PR tip
452 // - clone tag: URL where commit can be fetched
453 // - content: PR description
454 // 3. Send PR event
455 // 4. Verify acceptance
456 // 5. Query to confirm PR is stored
457
458 Err("Not implemented yet".to_string())
459 })
460 .await
461 }
462
463 /// Test: Accept issues (kind 1621) for accepted repos
464 ///
465 /// Spec: Lines 8-9 of ../grasp/01.md
466 /// Requirement: MUST accept issues for accepted repos
467 async fn test_accept_issue_for_repo(client: &AuditClient) -> TestResult {
468 TestResult::new(
469 "accept_issue_for_repo",
470 "GRASP-01:nostr-relay:8-9",
471 "Accept issue events (kind 1621) for accepted repositories",
472 )
473 .run(|| async {
474 // TODO: Implementation
475 // 1. Create and send kind 30617 repo announcement
476 // 2. Create kind 1621 issue event with:
477 // - a tag: "30617:{pubkey}:{d-tag}"
478 // - p tag: repo owner
479 // - subject tag: "Bug: Something is broken"
480 // - t tag: "bug" (label)
481 // - content: issue description
482 // 3. Send issue event
483 // 4. Verify acceptance
484 // 5. Query to confirm issue is stored
485
486 Err("Not implemented yet".to_string())
487 })
488 .await
489 }
490
491 /// Test: Accept replies to accepted patches/PRs/issues
492 ///
493 /// Spec: Lines 8-9 of ../grasp/01.md
494 /// Requirement: MUST accept replies to accepted events
495 async fn test_accept_reply_to_issue(client: &AuditClient) -> TestResult {
496 TestResult::new(
497 "accept_reply_to_issue",
498 "GRASP-01:nostr-relay:8-9",
499 "Accept reply events to accepted issues/patches/PRs",
500 )
501 .run(|| async {
502 // TODO: Implementation
503 // 1. Create and send kind 30617 repo announcement
504 // 2. Create and send kind 1621 issue
505 // 3. Create NIP-22 comment (kind 1111) replying to issue:
506 // - E tag: issue event ID
507 // - P tag: issue author
508 // - content: reply text
509 // 4. Send reply event
510 // 5. Verify acceptance
511 // 6. Query to confirm reply is stored
512
513 Err("Not implemented yet".to_string())
514 })
515 .await
516 }
517
518 // =========================================================================
519 // NIP-11 Relay Information Tests
520 // =========================================================================
521
522 /// Test: Serve NIP-11 document
523 ///
524 /// Spec: Line 11 of ../grasp/01.md
525 /// Requirement: MUST serve NIP-11 document
526 async fn test_nip11_document_exists(client: &AuditClient) -> TestResult {
527 TestResult::new(
528 "nip11_document_exists",
529 "GRASP-01:nostr-relay:11",
530 "Serve NIP-11 relay information document",
531 )
532 .run(|| async {
533 // TODO: Implementation
534 // 1. Extract HTTP(S) URL from client's WebSocket URL
535 // - ws://localhost:8081 -> http://localhost:8081
536 // - wss://relay.example.com -> https://relay.example.com
537 // 2. HTTP GET to base URL with header:
538 // - Accept: application/nostr+json
539 // 3. Verify 200 OK response
540 // 4. Verify response is valid JSON
541 // 5. Parse as NIP-11 document
542 // 6. Verify has required fields (name, description, etc.)
543
544 Err("Not implemented yet".to_string())
545 })
546 .await
547 }
548
549 /// Test: NIP-11 includes supported_grasps field
550 ///
551 /// Spec: Line 12 of ../grasp/01.md
552 /// Requirement: MUST list supported GRASPs as string array
553 async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult {
554 TestResult::new(
555 "nip11_supported_grasps_field",
556 "GRASP-01:nostr-relay:12",
557 "NIP-11 document includes supported_grasps field with GRASP-01",
558 )
559 .run(|| async {
560 // TODO: Implementation
561 // 1. Fetch NIP-11 document (same as above)
562 // 2. Verify `supported_grasps` field exists
563 // 3. Verify it's a JSON array of strings
564 // 4. Verify array includes "GRASP-01"
565 // 5. Verify format: each entry matches pattern "GRASP-\d{2}"
566 // 6. Document other GRASPs found (for info)
567
568 Err("Not implemented yet".to_string())
569 })
570 .await
571 }
572
573 /// Test: NIP-11 includes repo_acceptance_criteria field
574 ///
575 /// Spec: Line 13 of ../grasp/01.md
576 /// Requirement: MUST list repository acceptance criteria
577 async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult {
578 TestResult::new(
579 "nip11_repo_acceptance_criteria_field",
580 "GRASP-01:nostr-relay:13",
581 "NIP-11 document includes repo_acceptance_criteria field",
582 )
583 .run(|| async {
584 // TODO: Implementation
585 // 1. Fetch NIP-11 document
586 // 2. Verify `repo_acceptance_criteria` field exists
587 // 3. Verify it's a string (human-readable)
588 // 4. Verify non-empty
589 // 5. Document the criteria (for info)
590 // Examples: "Must list this relay in clone and relays tags"
591 // "Pre-payment required via Lightning invoice"
592
593 Err("Not implemented yet".to_string())
594 })
595 .await
596 }
597
598 /// Test: NIP-11 curation field handling
599 ///
600 /// Spec: Line 14 of ../grasp/01.md
601 /// Requirement: MUST include curation if curated, omit otherwise
602 async fn test_nip11_curation_field(client: &AuditClient) -> TestResult {
603 TestResult::new(
604 "nip11_curation_field",
605 "GRASP-01:nostr-relay:14",
606 "NIP-11 curation field present if curated, absent otherwise",
607 )
608 .run(|| async {
609 // TODO: Implementation
610 // 1. Fetch NIP-11 document
611 // 2. Check if `curation` field exists
612 // 3. If present:
613 // - Verify it's a non-empty string
614 // - Document the curation policy
615 // 4. If absent:
616 // - Document that no curation beyond SPAM prevention
617 // 5. Both cases are valid per spec
618
619 Err("Not implemented yet".to_string())
620 })
621 .await
622 }
623
624 // =========================================================================
625 // Policy Tests (Document Allowed Behavior)
626 // =========================================================================
627
628 /// Test: Custom rejection criteria allowed
629 ///
630 /// Spec: Line 6 of ../grasp/01.md
631 /// Requirement: MAY reject based on custom criteria (document behavior)
632 async fn test_custom_rejection_allowed(client: &AuditClient) -> TestResult {
633 TestResult::new(
634 "custom_rejection_allowed",
635 "GRASP-01:nostr-relay:6",
636 "Document that custom rejection criteria are allowed",
637 )
638 .run(|| async {
639 // TODO: Implementation
640 // This is a policy test, not a functional test
641 //
642 // The spec says relay MAY reject based on:
643 // - Pre-payment
644 // - Quotas
645 // - WoT (Web of Trust)
646 // - Whitelist
647 // - SPAM prevention
648 // - etc.
649 //
650 // This test should:
651 // 1. Document that such rejections are allowed
652 // 2. Check NIP-11 repo_acceptance_criteria for policy
653 // 3. Optionally test if relay enforces any criteria
654 // 4. Mark as PASS (this is permissive, not mandatory)
655
656 Ok(()) // This is always allowed
657 })
658 .await
659 }
660
661 /// Test: SPAM prevention allowed
662 ///
663 /// Spec: Line 10 of ../grasp/01.md
664 /// Requirement: MAY reject/delete for SPAM prevention
665 async fn test_spam_prevention_allowed(client: &AuditClient) -> TestResult {
666 TestResult::new(
667 "spam_prevention_allowed",
668 "GRASP-01:nostr-relay:10",
669 "Document that SPAM prevention is allowed",
670 )
671 .run(|| async {
672 // TODO: Implementation
673 // Similar to above - this is permissive
674 //
675 // The spec says relay MAY reject or delete events for:
676 // - Generic SPAM prevention
677 // - Curation (WoT, whitelist, user bans, banned topics)
678 //
679 // This test should:
680 // 1. Document that SPAM prevention is allowed
681 // 2. Check NIP-11 curation field for policy
682 // 3. Mark as PASS (this is implementation-specific)
683
684 Ok(()) // This is always allowed
685 })
686 .await
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::AuditConfig;
694
695#[tokio::test]
696#[ignore] // Requires running relay
697async fn test_grasp01_nostr_relay_against_relay() {
698 // Read relay URL from environment variable - must be supplied
699 let relay_url = std::env::var("RELAY_URL")
700 .expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081");
701
702 let config = AuditConfig::ci();
703 let client = AuditClient::new(&relay_url, config)
704 .await
705 .expect(&format!(
706 "Failed to connect to relay at {}. Ensure relay is running and accessible. \
707 Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest",
708 relay_url
709 ));
710
711 let results = Grasp01NostrRelayTests::run_all(&client).await;
712 results.print_report();
713
714 // Don't assert all passed yet - tests not implemented
715 // assert!(results.all_passed(), "Some GRASP-01 Nostr relay tests failed");
716 }
717}
diff --git a/grasp-audit/src/specs/mod.rs b/grasp-audit/src/specs/mod.rs
index 834bf9e..c1c277c 100644
--- a/grasp-audit/src/specs/mod.rs
+++ b/grasp-audit/src/specs/mod.rs
@@ -1,7 +1,10 @@
1//! Test specifications 1//! Test specifications
2 2
3pub mod grasp01_nostr_relay; 3pub mod grasp01;
4pub mod nip01_smoke;
5 4
6pub use grasp01_nostr_relay::Grasp01NostrRelayTests; 5// Re-export all test structs from grasp01 module
7pub use nip01_smoke::Nip01SmokeTests; 6pub use grasp01::{
7 EventAcceptancePolicyTests,
8 Nip01SmokeTests,
9 Nip11DocumentTests,
10};