upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-04 06:17:55 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-04 06:17:55 +0000
commit001ca45e385c05b0eaa36d9879e051853aaff107 (patch)
tree603fb85d2563db5b7c418e9fd143d479bd09676e /grasp-audit/src
parentd428baf30feec295870fadda2d335d1e7f89507b (diff)
created POC grasp-auditor
Diffstat (limited to 'grasp-audit/src')
-rw-r--r--grasp-audit/src/audit.rs188
-rw-r--r--grasp-audit/src/bin/grasp-audit.rs95
-rw-r--r--grasp-audit/src/client.rs146
-rw-r--r--grasp-audit/src/isolation.rs57
-rw-r--r--grasp-audit/src/lib.rs43
-rw-r--r--grasp-audit/src/result.rs189
-rw-r--r--grasp-audit/src/specs/mod.rs5
-rw-r--r--grasp-audit/src/specs/nip01_smoke.rs303
8 files changed, 1026 insertions, 0 deletions
diff --git a/grasp-audit/src/audit.rs b/grasp-audit/src/audit.rs
new file mode 100644
index 0000000..0ca8737
--- /dev/null
+++ b/grasp-audit/src/audit.rs
@@ -0,0 +1,188 @@
1//! Audit configuration and event tagging
2
3use nostr_sdk::prelude::*;
4use std::time::Duration;
5
6/// Audit configuration
7#[derive(Debug, Clone)]
8pub struct AuditConfig {
9 /// Unique ID for this audit run
10 pub run_id: String,
11
12 /// Mode: CI (isolated) or Production (live)
13 pub mode: AuditMode,
14
15 /// Cleanup timestamp (events can be cleaned after this)
16 pub cleanup_after: Timestamp,
17
18 /// Whether to actually create events or just query
19 pub read_only: bool,
20}
21
22/// Audit mode
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AuditMode {
25 /// Isolated CI/CD tests - only see own events
26 CI,
27
28 /// Production audit - see all events, minimal writes
29 Production,
30}
31
32impl AuditConfig {
33 /// Create config for CI/CD testing
34 pub fn ci() -> Self {
35 let run_id = format!("ci-{}", uuid::Uuid::new_v4());
36 Self {
37 run_id,
38 mode: AuditMode::CI,
39 cleanup_after: Timestamp::now() + 3600, // 1 hour from now
40 read_only: false,
41 }
42 }
43
44 /// Create config for production audit
45 pub fn production() -> Self {
46 let run_id = format!("prod-audit-{}", Timestamp::now().as_u64());
47 Self {
48 run_id,
49 mode: AuditMode::Production,
50 cleanup_after: Timestamp::now() + 300, // 5 minutes from now
51 read_only: true, // Default to read-only for production
52 }
53 }
54
55 /// Create config with custom run ID
56 pub fn with_run_id(run_id: String, mode: AuditMode) -> Self {
57 Self {
58 run_id,
59 mode,
60 cleanup_after: Timestamp::now() + 3600,
61 read_only: mode == AuditMode::Production,
62 }
63 }
64
65 /// Get audit tags for an event
66 pub fn audit_tags(&self) -> Vec<Tag> {
67 vec![
68 Tag::custom(
69 TagKind::Custom(std::borrow::Cow::Borrowed("grasp-audit")),
70 vec!["true"]
71 ),
72 Tag::custom(
73 TagKind::Custom(std::borrow::Cow::Borrowed("audit-run-id")),
74 vec![self.run_id.clone()]
75 ),
76 Tag::custom(
77 TagKind::Custom(std::borrow::Cow::Borrowed("audit-cleanup")),
78 vec![self.cleanup_after.to_string()]
79 ),
80 ]
81 }
82}
83
84/// Builder for audit events
85pub struct AuditEventBuilder {
86 kind: Kind,
87 content: String,
88 tags: Vec<Tag>,
89 config: AuditConfig,
90}
91
92impl AuditEventBuilder {
93 /// Create a new audit event builder
94 pub fn new(kind: Kind, content: impl Into<String>, config: AuditConfig) -> Self {
95 Self {
96 kind,
97 content: content.into(),
98 tags: Vec::new(),
99 config,
100 }
101 }
102
103 /// Add a tag
104 pub fn tag(mut self, tag: Tag) -> Self {
105 self.tags.push(tag);
106 self
107 }
108
109 /// Add multiple tags
110 pub fn tags(mut self, tags: Vec<Tag>) -> Self {
111 self.tags.extend(tags);
112 self
113 }
114
115 /// Build the event with audit tags
116 pub async fn build(self, keys: &Keys) -> anyhow::Result<Event> {
117 let mut all_tags = self.tags;
118 all_tags.extend(self.config.audit_tags());
119
120 let event = EventBuilder::new(self.kind, self.content, all_tags)
121 .to_event(keys)
122 .await?;
123
124 Ok(event)
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn test_ci_config() {
134 let config = AuditConfig::ci();
135 assert_eq!(config.mode, AuditMode::CI);
136 assert!(!config.read_only);
137 assert!(config.run_id.starts_with("ci-"));
138 }
139
140 #[test]
141 fn test_production_config() {
142 let config = AuditConfig::production();
143 assert_eq!(config.mode, AuditMode::Production);
144 assert!(config.read_only);
145 assert!(config.run_id.starts_with("prod-audit-"));
146 }
147
148 #[test]
149 fn test_audit_tags() {
150 let config = AuditConfig::ci();
151 let tags = config.audit_tags();
152
153 assert_eq!(tags.len(), 3);
154
155 // Check grasp-audit tag
156 assert!(tags.iter().any(|t| {
157 matches!(t.kind(), TagKind::Custom(k) if k == "grasp-audit")
158 }));
159
160 // Check audit-run-id tag
161 assert!(tags.iter().any(|t| {
162 matches!(t.kind(), TagKind::Custom(k) if k == "audit-run-id")
163 }));
164
165 // Check audit-cleanup tag
166 assert!(tags.iter().any(|t| {
167 matches!(t.kind(), TagKind::Custom(k) if k == "audit-cleanup")
168 }));
169 }
170
171 #[tokio::test]
172 async fn test_audit_event_builder() {
173 let config = AuditConfig::ci();
174 let keys = Keys::generate();
175
176 let event = AuditEventBuilder::new(Kind::TextNote, "test", config.clone())
177 .tag(Tag::custom(TagKind::Custom("test".into()), vec!["value"]))
178 .build(&keys)
179 .await
180 .unwrap();
181
182 // Should have our custom tag + 3 audit tags
183 assert!(event.tags.len() >= 4);
184
185 // Verify event is valid
186 assert!(event.verify().is_ok());
187 }
188}
diff --git a/grasp-audit/src/bin/grasp-audit.rs b/grasp-audit/src/bin/grasp-audit.rs
new file mode 100644
index 0000000..6c063db
--- /dev/null
+++ b/grasp-audit/src/bin/grasp-audit.rs
@@ -0,0 +1,95 @@
1//! GRASP Audit CLI Tool
2
3use clap::{Parser, Subcommand};
4use grasp_audit::*;
5
6#[derive(Parser)]
7#[command(name = "grasp-audit")]
8#[command(about = "GRASP audit and compliance testing tool", long_about = None)]
9struct Cli {
10 #[command(subcommand)]
11 command: Commands,
12}
13
14#[derive(Subcommand)]
15enum Commands {
16 /// Run audit tests against a server
17 Audit {
18 /// Relay URL (e.g., ws://localhost:7000)
19 #[arg(short, long)]
20 relay: String,
21
22 /// Mode: ci or production
23 #[arg(short, long, default_value = "ci")]
24 mode: String,
25
26 /// Spec to test (nip01-smoke, all)
27 #[arg(short, long, default_value = "nip01-smoke")]
28 spec: String,
29 },
30}
31
32#[tokio::main]
33async fn main() -> Result<()> {
34 // Initialize logging
35 tracing_subscriber::fmt()
36 .with_env_filter(
37 tracing_subscriber::EnvFilter::from_default_env()
38 .add_directive(tracing::Level::INFO.into())
39 )
40 .init();
41
42 let cli = Cli::parse();
43
44 match cli.command {
45 Commands::Audit { relay, mode, spec } => {
46 let config = match mode.as_str() {
47 "ci" => AuditConfig::ci(),
48 "production" => AuditConfig::production(),
49 _ => return Err(anyhow!("Invalid mode: {}. Use 'ci' or 'production'", mode)),
50 };
51
52 println!("🔍 GRASP Audit Tool");
53 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
54 println!("Relay: {}", relay);
55 println!("Mode: {}", mode);
56 println!("Spec: {}", spec);
57 println!("Run ID: {}", config.run_id);
58 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
59 println!();
60
61 println!("Connecting to relay...");
62 let client = AuditClient::new(&relay, config).await
63 .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
64
65 if !client.is_connected().await {
66 return Err(anyhow!("Could not establish connection to relay"));
67 }
68
69 println!("✓ Connected\n");
70
71 let results = match spec.as_str() {
72 "nip01-smoke" => {
73 println!("Running NIP-01 smoke tests...\n");
74 specs::Nip01SmokeTests::run_all(&client).await
75 }
76 "all" => {
77 println!("Running all tests...\n");
78 specs::Nip01SmokeTests::run_all(&client).await
79 }
80 _ => return Err(anyhow!("Unknown spec: {}. Use 'nip01-smoke' or 'all'", spec)),
81 };
82
83 results.print_report();
84
85 if !results.all_passed() {
86 println!("❌ Some tests failed");
87 std::process::exit(1);
88 } else {
89 println!("✅ All tests passed!");
90 }
91 }
92 }
93
94 Ok(())
95}
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs
new file mode 100644
index 0000000..934aef2
--- /dev/null
+++ b/grasp-audit/src/client.rs
@@ -0,0 +1,146 @@
1//! Audit client for testing GRASP implementations
2
3use crate::audit::{AuditConfig, AuditEventBuilder, AuditMode};
4use anyhow::{anyhow, Result};
5use nostr_sdk::prelude::*;
6use std::time::Duration;
7
8/// Client for auditing GRASP implementations
9pub struct AuditClient {
10 client: Client,
11 pub config: AuditConfig,
12 keys: Keys,
13}
14
15impl AuditClient {
16 /// Create a new audit client
17 pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> {
18 let keys = Keys::generate();
19 let client = Client::new(&keys);
20
21 client.add_relay(relay_url).await?;
22 client.connect().await;
23
24 // Wait a bit for connection to establish
25 tokio::time::sleep(Duration::from_millis(500)).await;
26
27 Ok(Self {
28 client,
29 config,
30 keys,
31 })
32 }
33
34 /// Get the public key for this audit client
35 pub fn public_key(&self) -> PublicKey {
36 self.keys.public_key()
37 }
38
39 /// Check if connected to relay
40 pub async fn is_connected(&self) -> bool {
41 // Check if we have any connected relays
42 let relays = self.client.relays().await;
43 relays.values().any(|r| r.is_connected())
44 }
45
46 /// Send an event (with audit tags automatically added)
47 pub async fn send_event(&self, event: Event) -> Result<EventId> {
48 if self.config.read_only {
49 return Err(anyhow!("Client is in read-only mode"));
50 }
51
52 let event_id = self.client.send_event(event).await?;
53
54 // Wait a bit for event to propagate
55 tokio::time::sleep(Duration::from_millis(100)).await;
56
57 Ok(event_id)
58 }
59
60 /// Create an event builder with audit tags
61 pub fn event_builder(&self, kind: Kind, content: impl Into<String>) -> AuditEventBuilder {
62 AuditEventBuilder::new(kind, content, self.config.clone())
63 }
64
65 /// Query events, optionally filtered to this audit run
66 pub async fn query(&self, mut filter: Filter) -> Result<Vec<Event>> {
67 if self.config.mode == AuditMode::CI {
68 // In CI mode, only see our own audit events
69 filter = filter
70 .custom_tag(
71 SingleLetterTag::lowercase(Alphabet::G),
72 ["true"] // grasp-audit tag
73 )
74 .custom_tag(
75 SingleLetterTag::lowercase(Alphabet::R),
76 [&self.config.run_id] // audit-run-id tag
77 );
78 }
79 // In Production mode, see all events (no filter modification)
80
81 let events = self.client
82 .get_events_of(vec![filter], Some(Duration::from_secs(5)))
83 .await?;
84
85 Ok(events)
86 }
87
88 /// Subscribe to events with a callback
89 pub async fn subscribe(
90 &self,
91 filters: Vec<Filter>,
92 timeout: Option<Duration>,
93 ) -> Result<Vec<Event>> {
94 let events = self.client
95 .get_events_of(filters, timeout)
96 .await?;
97
98 Ok(events)
99 }
100
101 /// Get the underlying nostr client (for advanced usage)
102 pub fn client(&self) -> &Client {
103 &self.client
104 }
105
106 /// Get the keys (for signing custom events)
107 pub fn keys(&self) -> &Keys {
108 &self.keys
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[tokio::test]
117 async fn test_client_creation() {
118 let config = AuditConfig::ci();
119
120 // This will fail if no relay is running, which is expected in tests
121 // In real usage, there should be a relay at the URL
122 let result = AuditClient::new("ws://localhost:7000", config).await;
123
124 // We can't test connection without a running relay
125 // But we can test that the client is created
126 if let Ok(client) = result {
127 assert_eq!(client.config.mode, AuditMode::CI);
128 }
129 }
130
131 #[test]
132 fn test_event_builder() {
133 let config = AuditConfig::ci();
134 let keys = Keys::generate();
135 let client = AuditClient {
136 client: Client::new(&keys),
137 config: config.clone(),
138 keys: keys.clone(),
139 };
140
141 let builder = client.event_builder(Kind::TextNote, "test content");
142
143 // Builder should have the config
144 assert_eq!(builder.config.run_id, config.run_id);
145 }
146}
diff --git a/grasp-audit/src/isolation.rs b/grasp-audit/src/isolation.rs
new file mode 100644
index 0000000..298781a
--- /dev/null
+++ b/grasp-audit/src/isolation.rs
@@ -0,0 +1,57 @@
1//! Test isolation utilities
2
3use std::sync::atomic::{AtomicU64, Ordering};
4
5static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
6
7/// Generate a unique test ID
8pub fn generate_test_id() -> String {
9 let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
10 let timestamp = std::time::SystemTime::now()
11 .duration_since(std::time::UNIX_EPOCH)
12 .unwrap()
13 .as_secs();
14
15 format!("test-{}-{}", timestamp, counter)
16}
17
18/// Generate a unique audit run ID for CI
19pub fn generate_ci_run_id() -> String {
20 format!("ci-{}", uuid::Uuid::new_v4())
21}
22
23/// Generate a unique audit run ID for production
24pub fn generate_prod_run_id() -> String {
25 let timestamp = std::time::SystemTime::now()
26 .duration_since(std::time::UNIX_EPOCH)
27 .unwrap()
28 .as_secs();
29
30 format!("prod-audit-{}", timestamp)
31}
32
33#[cfg(test)]
34mod tests {
35 use super::*;
36
37 #[test]
38 fn test_generate_test_id() {
39 let id1 = generate_test_id();
40 let id2 = generate_test_id();
41
42 assert_ne!(id1, id2);
43 assert!(id1.starts_with("test-"));
44 }
45
46 #[test]
47 fn test_generate_ci_run_id() {
48 let id = generate_ci_run_id();
49 assert!(id.starts_with("ci-"));
50 }
51
52 #[test]
53 fn test_generate_prod_run_id() {
54 let id = generate_prod_run_id();
55 assert!(id.starts_with("prod-audit-"));
56 }
57}
diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs
new file mode 100644
index 0000000..3a6404f
--- /dev/null
+++ b/grasp-audit/src/lib.rs
@@ -0,0 +1,43 @@
1//! GRASP Audit Tool
2//!
3//! A reusable compliance and audit testing tool for GRASP protocol implementations.
4//!
5//! # Features
6//!
7//! - **Isolated Testing**: Tests run in parallel with unique audit IDs
8//! - **Production Audit**: Test live services with minimal impact
9//! - **Clean Audit Events**: Special tags for easy cleanup without deletion trails
10//! - **Spec-Mirrored Tests**: Test structure matches GRASP protocol exactly
11//!
12//! # Usage
13//!
14//! ```no_run
15//! use grasp_audit::*;
16//!
17//! #[tokio::main]
18//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
19//! // Create audit client for CI testing
20//! let config = AuditConfig::ci();
21//! let client = AuditClient::new("ws://localhost:7000", config).await?;
22//!
23//! // Run smoke tests
24//! let results = specs::nip01_smoke::Nip01SmokeTests::run_all(&client).await;
25//! results.print_report();
26//!
27//! Ok(())
28//! }
29//! ```
30
31pub mod audit;
32pub mod client;
33pub mod isolation;
34pub mod result;
35pub mod specs;
36
37pub use audit::{AuditConfig, AuditMode};
38pub use client::AuditClient;
39pub use result::{AuditResult, TestResult};
40
41// Re-export commonly used types
42pub use anyhow::{anyhow, Result};
43pub use nostr_sdk::prelude::*;
diff --git a/grasp-audit/src/result.rs b/grasp-audit/src/result.rs
new file mode 100644
index 0000000..f591304
--- /dev/null
+++ b/grasp-audit/src/result.rs
@@ -0,0 +1,189 @@
1//! Test result types
2
3use std::time::{Duration, Instant};
4
5/// Result of a single test
6#[derive(Debug, Clone)]
7pub struct TestResult {
8 pub name: String,
9 pub spec_ref: String,
10 pub requirement: String,
11 pub passed: bool,
12 pub error: Option<String>,
13 pub duration: Duration,
14}
15
16impl TestResult {
17 /// Create a new test result
18 pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self {
19 TestResult {
20 name: name.to_string(),
21 spec_ref: spec_ref.to_string(),
22 requirement: requirement.to_string(),
23 passed: false,
24 error: None,
25 duration: Duration::default(),
26 }
27 }
28
29 /// Run a test function and capture the result
30 pub async fn run<F, Fut>(mut self, test_fn: F) -> Self
31 where
32 F: FnOnce() -> Fut,
33 Fut: std::future::Future<Output = Result<(), String>>,
34 {
35 let start = Instant::now();
36
37 match test_fn().await {
38 Ok(()) => {
39 self.passed = true;
40 }
41 Err(e) => {
42 self.passed = false;
43 self.error = Some(e);
44 }
45 }
46
47 self.duration = start.elapsed();
48 self
49 }
50
51 /// Mark test as passed
52 pub fn pass(mut self) -> Self {
53 self.passed = true;
54 self
55 }
56
57 /// Mark test as failed with error
58 pub fn fail(mut self, error: impl Into<String>) -> Self {
59 self.passed = false;
60 self.error = Some(error.into());
61 self
62 }
63}
64
65/// Collection of test results for a spec
66#[derive(Debug, Clone)]
67pub struct AuditResult {
68 pub spec: String,
69 pub results: Vec<TestResult>,
70}
71
72impl AuditResult {
73 /// Create a new audit result
74 pub fn new(spec: impl Into<String>) -> Self {
75 Self {
76 spec: spec.into(),
77 results: Vec::new(),
78 }
79 }
80
81 /// Add a test result
82 pub fn add(&mut self, result: TestResult) {
83 self.results.push(result);
84 }
85
86 /// Merge another audit result
87 pub fn merge(&mut self, other: AuditResult) {
88 self.results.extend(other.results);
89 }
90
91 /// Check if all tests passed
92 pub fn all_passed(&self) -> bool {
93 self.results.iter().all(|r| r.passed)
94 }
95
96 /// Get count of passed tests
97 pub fn passed_count(&self) -> usize {
98 self.results.iter().filter(|r| r.passed).count()
99 }
100
101 /// Get count of failed tests
102 pub fn failed_count(&self) -> usize {
103 self.results.iter().filter(|r| !r.passed).count()
104 }
105
106 /// Get total count of tests
107 pub fn total_count(&self) -> usize {
108 self.results.len()
109 }
110
111 /// Print a detailed report
112 pub fn print_report(&self) {
113 println!("\n{}", self.spec);
114 println!("{}", "═".repeat(60));
115 println!();
116
117 let passed = self.passed_count();
118 let total = self.total_count();
119
120 for result in &self.results {
121 let status = if result.passed { "✓" } else { "✗" };
122
123 println!("{} {} ({})", status, result.name, result.spec_ref);
124 println!(" Requirement: {}", result.requirement);
125
126 if let Some(error) = &result.error {
127 println!(" Error: {}", error);
128 }
129
130 println!(" Duration: {:?}", result.duration);
131 println!();
132 }
133
134 println!("Results: {}/{} passed ({:.1}%)",
135 passed,
136 total,
137 (passed as f64 / total as f64) * 100.0
138 );
139 println!();
140 }
141
142 /// Get a summary string
143 pub fn summary(&self) -> String {
144 format!(
145 "{}: {}/{} passed",
146 self.spec,
147 self.passed_count(),
148 self.total_count()
149 )
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[tokio::test]
158 async fn test_result_pass() {
159 let result = TestResult::new("test", "SPEC:1", "Must work")
160 .run(|| async { Ok(()) })
161 .await;
162
163 assert!(result.passed);
164 assert!(result.error.is_none());
165 }
166
167 #[tokio::test]
168 async fn test_result_fail() {
169 let result = TestResult::new("test", "SPEC:1", "Must work")
170 .run(|| async { Err("Failed".to_string()) })
171 .await;
172
173 assert!(!result.passed);
174 assert_eq!(result.error, Some("Failed".to_string()));
175 }
176
177 #[test]
178 fn test_audit_result() {
179 let mut audit = AuditResult::new("Test Spec");
180
181 audit.add(TestResult::new("test1", "SPEC:1", "Req1").pass());
182 audit.add(TestResult::new("test2", "SPEC:2", "Req2").fail("Error"));
183
184 assert_eq!(audit.total_count(), 2);
185 assert_eq!(audit.passed_count(), 1);
186 assert_eq!(audit.failed_count(), 1);
187 assert!(!audit.all_passed());
188 }
189}
diff --git a/grasp-audit/src/specs/mod.rs b/grasp-audit/src/specs/mod.rs
new file mode 100644
index 0000000..451ea1f
--- /dev/null
+++ b/grasp-audit/src/specs/mod.rs
@@ -0,0 +1,5 @@
1//! Test specifications
2
3pub mod nip01_smoke;
4
5pub use nip01_smoke::Nip01SmokeTests;
diff --git a/grasp-audit/src/specs/nip01_smoke.rs b/grasp-audit/src/specs/nip01_smoke.rs
new file mode 100644
index 0000000..fc3ec29
--- /dev/null
+++ b/grasp-audit/src/specs/nip01_smoke.rs
@@ -0,0 +1,303 @@
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 in parallel
18 let tests = vec![
19 Self::test_websocket_connection(client),
20 Self::test_send_receive_event(client),
21 Self::test_create_subscription(client),
22 Self::test_close_subscription(client),
23 Self::test_reject_invalid_signature(client),
24 Self::test_reject_invalid_event_id(client),
25 ];
26
27 let test_results = futures::future::join_all(tests).await;
28
29 for result in test_results {
30 results.add(result);
31 }
32
33 results
34 }
35
36 /// Test 1: Can establish WebSocket connection
37 ///
38 /// Spec: NIP-01 basic requirement
39 /// Requirement: MUST serve a relay at / via WebSocket
40 async fn test_websocket_connection(client: &AuditClient) -> TestResult {
41 TestResult::new(
42 "websocket_connection",
43 "NIP-01:basic",
44 "Can establish WebSocket connection to /",
45 )
46 .run(|| async {
47 if !client.is_connected().await {
48 return Err("Failed to connect to relay".to_string());
49 }
50
51 Ok(())
52 })
53 .await
54 }
55
56 /// Test 2: Can send EVENT and receive OK response
57 ///
58 /// Spec: NIP-01 EVENT message
59 /// Requirement: Relay MUST accept valid EVENT messages
60 async fn test_send_receive_event(client: &AuditClient) -> TestResult {
61 TestResult::new(
62 "send_receive_event",
63 "NIP-01:event-message",
64 "Can send EVENT and receive OK response",
65 )
66 .run(|| async {
67 // Create audit event
68 let event = client
69 .event_builder(Kind::TextNote, "NIP-01 smoke test event")
70 .build(client.keys())
71 .await
72 .map_err(|e| format!("Failed to build event: {}", e))?;
73
74 // Send event
75 let event_id = client
76 .send_event(event.clone())
77 .await
78 .map_err(|e| format!("Failed to send event: {}", e))?;
79
80 // Verify we got an event ID back
81 if event_id != event.id {
82 return Err(format!(
83 "Event ID mismatch: sent {}, got {}",
84 event.id, event_id
85 ));
86 }
87
88 // Try to query it back
89 let filter = Filter::new()
90 .kind(Kind::TextNote)
91 .id(event_id);
92
93 let events = client
94 .query(filter)
95 .await
96 .map_err(|e| format!("Failed to query event: {}", e))?;
97
98 if events.is_empty() {
99 return Err("Event not found after sending".to_string());
100 }
101
102 if events[0].id != event_id {
103 return Err("Retrieved event has different ID".to_string());
104 }
105
106 Ok(())
107 })
108 .await
109 }
110
111 /// Test 3: Can create subscription with REQ
112 ///
113 /// Spec: NIP-01 REQ message
114 /// Requirement: Relay MUST support REQ subscriptions
115 async fn test_create_subscription(client: &AuditClient) -> TestResult {
116 TestResult::new(
117 "create_subscription",
118 "NIP-01:req-message",
119 "Can create subscription with REQ and receive EOSE",
120 )
121 .run(|| async {
122 // Create a test event first
123 let event = client
124 .event_builder(Kind::TextNote, "Subscription test event")
125 .build(client.keys())
126 .await
127 .map_err(|e| format!("Failed to build event: {}", e))?;
128
129 client
130 .send_event(event.clone())
131 .await
132 .map_err(|e| format!("Failed to send event: {}", e))?;
133
134 // Subscribe to events
135 let filter = Filter::new()
136 .kind(Kind::TextNote)
137 .author(client.public_key());
138
139 let events = client
140 .subscribe(vec![filter], Some(std::time::Duration::from_secs(5)))
141 .await
142 .map_err(|e| format!("Failed to subscribe: {}", e))?;
143
144 // Should have at least our event
145 if events.is_empty() {
146 return Err("No events received from subscription".to_string());
147 }
148
149 Ok(())
150 })
151 .await
152 }
153
154 /// Test 4: Can close subscription with CLOSE
155 ///
156 /// Spec: NIP-01 CLOSE message
157 /// Requirement: Relay MUST support CLOSE to end subscriptions
158 async fn test_close_subscription(client: &AuditClient) -> TestResult {
159 TestResult::new(
160 "close_subscription",
161 "NIP-01:close-message",
162 "Can close subscriptions",
163 )
164 .run(|| async {
165 // For now, we just verify we can query events
166 // Full subscription management with CLOSE would require
167 // lower-level WebSocket access
168
169 let filter = Filter::new()
170 .kind(Kind::TextNote)
171 .limit(1);
172
173 let _events = client
174 .subscribe(vec![filter], Some(std::time::Duration::from_secs(2)))
175 .await
176 .map_err(|e| format!("Failed to subscribe: {}", e))?;
177
178 // If we got here, subscription worked
179 Ok(())
180 })
181 .await
182 }
183
184 /// Test 5: Rejects events with invalid signatures
185 ///
186 /// Spec: NIP-01 event validation
187 /// Requirement: Relay MUST reject events with invalid signatures
188 async fn test_reject_invalid_signature(client: &AuditClient) -> TestResult {
189 TestResult::new(
190 "reject_invalid_signature",
191 "NIP-01:validation",
192 "Rejects events with invalid signatures",
193 )
194 .run(|| async {
195 // Create a valid event
196 let mut event = client
197 .event_builder(Kind::TextNote, "Invalid signature test")
198 .build(client.keys())
199 .await
200 .map_err(|e| format!("Failed to build event: {}", e))?;
201
202 // Corrupt the signature by creating a new event with wrong sig
203 // We'll use a different key to sign, creating an invalid signature
204 let wrong_keys = Keys::generate();
205 let wrong_event = EventBuilder::new(
206 event.kind,
207 event.content.clone(),
208 event.tags.clone(),
209 )
210 .to_event(&wrong_keys)
211 .await
212 .map_err(|e| format!("Failed to build wrong event: {}", e))?;
213
214 // Create event with mismatched pubkey and signature
215 // This should be rejected by the relay
216 event = Event {
217 id: event.id,
218 pubkey: event.pubkey,
219 created_at: event.created_at,
220 kind: event.kind,
221 tags: event.tags,
222 content: event.content,
223 sig: wrong_event.sig, // Wrong signature!
224 };
225
226 // Try to send the invalid event
227 let result = client.send_event(event).await;
228
229 // We expect this to fail
230 if result.is_ok() {
231 return Err("Relay accepted event with invalid signature".to_string());
232 }
233
234 Ok(())
235 })
236 .await
237 }
238
239 /// Test 6: Rejects events with invalid event IDs
240 ///
241 /// Spec: NIP-01 event ID validation
242 /// Requirement: Relay MUST reject events where ID doesn't match hash
243 async fn test_reject_invalid_event_id(client: &AuditClient) -> TestResult {
244 TestResult::new(
245 "reject_invalid_event_id",
246 "NIP-01:validation",
247 "Rejects events with invalid event IDs",
248 )
249 .run(|| async {
250 // Create a valid event
251 let mut event = client
252 .event_builder(Kind::TextNote, "Invalid ID test")
253 .build(client.keys())
254 .await
255 .map_err(|e| format!("Failed to build event: {}", e))?;
256
257 // Corrupt the ID
258 event = Event {
259 id: EventId::all_zeros(), // Wrong ID!
260 pubkey: event.pubkey,
261 created_at: event.created_at,
262 kind: event.kind,
263 tags: event.tags,
264 content: event.content,
265 sig: event.sig,
266 };
267
268 // Try to send the invalid event
269 let result = client.send_event(event).await;
270
271 // We expect this to fail
272 if result.is_ok() {
273 return Err("Relay accepted event with invalid ID".to_string());
274 }
275
276 Ok(())
277 })
278 .await
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::AuditConfig;
286
287 // Note: These tests require a running relay
288 // They are integration tests, not unit tests
289
290 #[tokio::test]
291 #[ignore] // Ignore by default since it needs a running relay
292 async fn test_smoke_tests_against_relay() {
293 let config = AuditConfig::ci();
294 let client = AuditClient::new("ws://localhost:7000", config)
295 .await
296 .expect("Failed to connect to relay");
297
298 let results = Nip01SmokeTests::run_all(&client).await;
299 results.print_report();
300
301 assert!(results.all_passed(), "Some smoke tests failed");
302 }
303}