upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src/specs/grasp01
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/src/specs/grasp01
parentb22cb23928ef799b0a5d362003d3084d2ab267b4 (diff)
restructure grasp01 audit tests and add event acceptance
Diffstat (limited to 'grasp-audit/src/specs/grasp01')
-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.rs311
-rw-r--r--grasp-audit/src/specs/grasp01/nip11_document.rs165
4 files changed, 1480 insertions, 0 deletions
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/grasp01/nip01_smoke.rs b/grasp-audit/src/specs/grasp01/nip01_smoke.rs
new file mode 100644
index 0000000..9ed0f56
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/nip01_smoke.rs
@@ -0,0 +1,311 @@
1//! NIP-01 Smoke Tests
2//!
3//! These tests verify basic Nostr relay functionality.
4//! We don't comprehensively test NIP-01 because rust-nostr already has 1000+ tests.
5//! These are just smoke tests to ensure the relay is working at all.
6
7use crate::{AuditClient, AuditResult, TestResult};
8use nostr_sdk::prelude::*;
9
10pub struct Nip01SmokeTests;
11
12impl Nip01SmokeTests {
13 /// Run all NIP-01 smoke tests
14 pub async fn run_all(client: &AuditClient) -> AuditResult {
15 let mut results = AuditResult::new("NIP-01 Smoke Tests");
16
17 // Run tests sequentially to avoid future type issues
18 results.add(Self::test_websocket_connection(client).await);
19 results.add(Self::test_send_receive_event(client).await);
20 results.add(Self::test_create_subscription(client).await);
21 results.add(Self::test_close_subscription(client).await);
22 results.add(Self::test_reject_invalid_signature(client).await);
23 results.add(Self::test_reject_invalid_event_id(client).await);
24
25 results
26 }
27
28 /// Test 1: Can establish WebSocket connection
29 ///
30 /// Spec: NIP-01 basic requirement
31 /// Requirement: MUST serve a relay at / via WebSocket
32 async fn test_websocket_connection(client: &AuditClient) -> TestResult {
33 TestResult::new(
34 "websocket_connection",
35 "NIP-01:basic",
36 "Can establish WebSocket connection to /",
37 )
38 .run(|| async {
39 if !client.is_connected().await {
40 return Err("Failed to connect to relay".to_string());
41 }
42
43 Ok(())
44 })
45 .await
46 }
47
48 /// Test 2: Can send EVENT and receive OK response
49 ///
50 /// Spec: NIP-01 EVENT message
51 /// Requirement: Relay MUST accept valid EVENT messages
52 ///
53 /// For GRASP servers, we send a NIP-34 repository announcement that lists
54 /// the GRASP server in clone and relays tags (required for acceptance).
55 async fn test_send_receive_event(client: &AuditClient) -> TestResult {
56 TestResult::new(
57 "send_receive_event",
58 "NIP-01:event-message",
59 "Can send EVENT and receive OK response",
60 )
61 .run(|| async {
62 // Create a NIP-34 announcement event
63 let event = client.create_repo_announcement("send_receive_event").await
64 .map_err(|e| format!("Failed to create announcement: {}", e))?;
65
66 // Send event
67 let event_id = client
68 .send_event(event.clone())
69 .await
70 .map_err(|e| format!("Failed to send event: {}", e))?;
71
72 // Verify we got an event ID back
73 if event_id != event.id {
74 return Err(format!(
75 "Event ID mismatch: sent {}, got {}",
76 event.id, event_id
77 ));
78 }
79
80 // Wait a bit for event to be indexed
81 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
82
83 // Try to query it back
84 let filter = Filter::new()
85 .kind(Kind::Custom(30617))
86 .id(event_id);
87
88 let events = client
89 .query(filter)
90 .await
91 .map_err(|e| format!("Failed to query event: {}", e))?;
92
93 if events.is_empty() {
94 // Debug: try querying without audit client filtering
95 eprintln!("Event not found with audit client query, trying direct client query...");
96 let direct_filter = Filter::new().kind(Kind::Custom(30617)).id(event_id);
97 let direct_events = client.client().fetch_events(direct_filter, std::time::Duration::from_secs(5)).await
98 .map_err(|e| format!("Direct query failed: {}", e))?;
99 let direct_vec: Vec<Event> = direct_events.into_iter().collect();
100 eprintln!("Direct query found {} events", direct_vec.len());
101 if !direct_vec.is_empty() {
102 eprintln!("Event tags: {:?}", direct_vec[0].tags);
103 }
104 return Err(format!("Event not found after sending (direct query found {})", direct_vec.len()));
105 }
106
107 if events[0].id != event_id {
108 return Err("Retrieved event has different ID".to_string());
109 }
110
111 Ok(())
112 })
113 .await
114 }
115
116 /// Test 3: Can create subscription with REQ
117 ///
118 /// Spec: NIP-01 REQ message
119 /// Requirement: Relay MUST support REQ subscriptions
120 async fn test_create_subscription(client: &AuditClient) -> TestResult {
121 TestResult::new(
122 "create_subscription",
123 "NIP-01:req-message",
124 "Can create subscription with REQ and receive EOSE",
125 )
126 .run(|| async {
127 // Create a NIP-34 announcement event (accepted by GRASP relays)
128 let event = client.create_repo_announcement("create_subscription").await
129 .map_err(|e| format!("Failed to create announcement: {}", e))?;
130
131 let event_id = client
132 .send_event(event.clone())
133 .await
134 .map_err(|e| format!("Failed to send event: {}", e))?;
135
136 // Subscribe to NIP-34 announcements from this author
137 let filter = Filter::new()
138 .kind(Kind::Custom(30617))
139 .author(client.public_key());
140
141 let events = client
142 .subscribe(vec![filter], Some(std::time::Duration::from_secs(5)))
143 .await
144 .map_err(|e| format!("Failed to subscribe: {}", e))?;
145
146 // Should have at least our event
147 if events.is_empty() {
148 return Err("No events received from subscription".to_string());
149 }
150
151 Ok(())
152 })
153 .await
154 }
155
156 /// Test 4: Can close subscription with CLOSE
157 ///
158 /// Spec: NIP-01 CLOSE message
159 /// Requirement: Relay MUST support CLOSE to end subscriptions
160 async fn test_close_subscription(client: &AuditClient) -> TestResult {
161 TestResult::new(
162 "close_subscription",
163 "NIP-01:close-message",
164 "Can close subscriptions",
165 )
166 .run(|| async {
167 // For now, we just verify we can query events
168 // Full subscription management with CLOSE would require
169 // lower-level WebSocket access
170
171 let filter = Filter::new()
172 .kind(Kind::TextNote)
173 .limit(1);
174
175 let _events = client
176 .subscribe(vec![filter], Some(std::time::Duration::from_secs(2)))
177 .await
178 .map_err(|e| format!("Failed to subscribe: {}", e))?;
179
180 // If we got here, subscription worked
181 Ok(())
182 })
183 .await
184 }
185
186 /// Test 5: Rejects events with invalid signatures
187 ///
188 /// Spec: NIP-01 event validation
189 /// Requirement: Relay MUST reject events with invalid signatures
190 async fn test_reject_invalid_signature(client: &AuditClient) -> TestResult {
191 TestResult::new(
192 "reject_invalid_signature",
193 "NIP-01:validation",
194 "Rejects events with invalid signatures",
195 )
196 .run(|| async {
197 // Create a valid event
198 let event = client
199 .event_builder(Kind::TextNote, "Invalid signature test")
200 .build(client.keys())
201 .map_err(|e| format!("Failed to build event: {}", e))?;
202
203 // Corrupt the signature by creating a new event with wrong sig
204 // We'll use a different key to sign, creating an invalid signature
205 let wrong_keys = Keys::generate();
206 let wrong_event = EventBuilder::new(event.kind, event.content.clone())
207 .tags(event.tags.clone())
208 .sign_with_keys(&wrong_keys)
209 .map_err(|e| format!("Failed to build wrong event: {}", e))?;
210
211 // Create event JSON with mismatched pubkey and signature
212 // This should be rejected by the relay
213 let invalid_event_json = serde_json::json!({
214 "id": event.id.to_hex(),
215 "pubkey": event.pubkey.to_hex(),
216 "created_at": event.created_at.as_u64(),
217 "kind": event.kind.as_u16(),
218 "tags": event.tags,
219 "content": event.content,
220 "sig": wrong_event.sig.to_string(), // Wrong signature!
221 });
222
223 // Parse it back to an Event
224 let invalid_event: Event = serde_json::from_value(invalid_event_json)
225 .map_err(|e| format!("Failed to create invalid event: {}", e))?;
226
227 // Try to send the invalid event
228 let result = client.send_event(invalid_event).await;
229
230 // We expect this to fail
231 if result.is_ok() {
232 return Err("Relay accepted event with invalid signature".to_string());
233 }
234
235 Ok(())
236 })
237 .await
238 }
239
240 /// Test 6: Rejects events with invalid event IDs
241 ///
242 /// Spec: NIP-01 event ID validation
243 /// Requirement: Relay MUST reject events where ID doesn't match hash
244 async fn test_reject_invalid_event_id(client: &AuditClient) -> TestResult {
245 TestResult::new(
246 "reject_invalid_event_id",
247 "NIP-01:validation",
248 "Rejects events with invalid event IDs",
249 )
250 .run(|| async {
251 // Create a valid event
252 let event = client
253 .event_builder(Kind::TextNote, "Invalid ID test")
254 .build(client.keys())
255 .map_err(|e| format!("Failed to build event: {}", e))?;
256
257 // Create event JSON with corrupted ID
258 let invalid_event_json = serde_json::json!({
259 "id": EventId::all_zeros().to_hex(), // Wrong ID!
260 "pubkey": event.pubkey.to_hex(),
261 "created_at": event.created_at.as_u64(),
262 "kind": event.kind.as_u16(),
263 "tags": event.tags,
264 "content": event.content,
265 "sig": event.sig.to_string(),
266 });
267
268 // Parse it back to an Event
269 let invalid_event: Event = serde_json::from_value(invalid_event_json)
270 .map_err(|e| format!("Failed to create invalid event: {}", e))?;
271
272 // Try to send the invalid event
273 let result = client.send_event(invalid_event).await;
274
275 // We expect this to fail
276 if result.is_ok() {
277 return Err("Relay accepted event with invalid ID".to_string());
278 }
279
280 Ok(())
281 })
282 .await
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::AuditConfig;
290
291 // Note: These tests require a running relay
292 // They are integration tests, not unit tests
293
294 #[tokio::test]
295 #[ignore] // Ignore by default since it needs a running relay
296 async fn test_smoke_tests_against_relay() {
297 // RELAY_URL env var must be set - no default fallback
298 let relay_url = std::env::var("RELAY_URL")
299 .expect("RELAY_URL environment variable must be set for integration tests");
300
301 let config = AuditConfig::ci();
302 let client = AuditClient::new(&relay_url, config)
303 .await
304 .expect("Failed to connect to relay");
305
306 let results = Nip01SmokeTests::run_all(&client).await;
307 results.print_report();
308
309 assert!(results.all_passed(), "Some smoke tests failed");
310 }
311}
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