1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
|
//! GRASP Audit CLI Tool
use clap::{CommandFactory, Parser, Subcommand};
use grasp_audit::*;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Parser)]
#[command(name = "grasp-audit")]
#[command(about = "GRASP audit and compliance testing tool", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Run a probe/smoke test against a server
Probe {
/// Relay URL (e.g., wss://relay.ngit.dev)
#[arg(short, long)]
relay: Option<String>,
/// Output machine-readable JSON
#[arg(long, default_value_t = false)]
json: bool,
/// Per-step timeout in seconds
#[arg(long, default_value_t = 30)]
timeout: u64,
/// Re-run every N seconds (watch mode)
#[arg(long)]
watch: Option<u64>,
/// Secret key in nsec bech32 format (for whitelisted relays)
#[arg(long)]
nsec: Option<String>,
/// Create a test repo on the relay to verify the full write path
/// (publish events, git push, verify refs match state).
/// Requires write access; use --nsec for whitelisted relays.
#[arg(long, default_value_t = false)]
create_repo: bool,
},
/// Run audit tests against a server
Audit {
/// Relay URL (e.g., ws://localhost:7000)
#[arg(short, long)]
relay: String,
/// Fixture mode: shared (default) or isolated
///
/// - shared: Fixtures are cached and reused across tests (efficient for sequential test runs)
/// - isolated: Each test creates fresh fixtures (for parallel tests like cargo test)
#[arg(short, long, default_value = "shared")]
mode: String,
/// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, git-filter, push-auth, repo-creation, purgatory, all)
#[arg(short, long, default_value = "all")]
spec: String,
/// Git data directory (required for cors, git-clone, push-auth, repo-creation specs)
#[arg(short, long)]
git_data_dir: Option<PathBuf>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
// Probe output is self-contained — library chatter (nostr_relay_pool etc.)
// adds no value and clutters both human and JSON output. Skip the tracing
// subscriber entirely for the probe subcommand; initialise it normally for
// audit subcommands where verbose output is expected.
let is_probe = std::env::args().nth(1).as_deref() == Some("probe");
if !is_probe {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
}
let cli = Cli::parse();
match cli.command {
Commands::Probe {
relay,
json,
timeout,
watch,
nsec,
create_repo,
} => {
let relay = match relay {
Some(r) => r,
None => {
// Print probe-specific help and exit cleanly
let mut cmd = Cli::command();
let _ = cmd.find_subcommand_mut("probe").unwrap().print_help();
println!();
return Ok(());
}
};
// Parse nsec if provided
let keys = if let Some(nsec_str) = nsec {
use nostr_sdk::prelude::SecretKey;
let sk = SecretKey::from_bech32(&nsec_str)
.map_err(|e| anyhow!("Invalid nsec: {}", e))?;
Some(Keys::new(sk))
} else {
None
};
// read_only is the default; --create-repo opts into the write path
let read_only = !create_repo;
// Overall probe timeout: min(20s, watch_interval) to prevent
// overlapping runs under --watch or cron scheduling.
let overall_secs = match watch {
Some(interval) => interval.min(20),
None => 20,
};
if let Some(interval) = watch {
let mut run = 1u64;
loop {
if !json {
println!("\n[Run {}]", run);
}
let report = grasp_audit::probe::run_probe(
&relay, keys.clone(), read_only, timeout, overall_secs,
)
.await;
if json {
report.print_json();
} else {
report.print_human();
}
run += 1;
tokio::time::sleep(Duration::from_secs(interval)).await;
}
} else {
let report = grasp_audit::probe::run_probe(
&relay, keys, read_only, timeout, overall_secs,
)
.await;
if json {
report.print_json();
} else {
report.print_human();
}
if !report.all_passed {
std::process::exit(1);
}
}
}
Commands::Audit {
relay,
mode,
spec,
git_data_dir,
} => {
let mut config = match mode.as_str() {
"shared" => AuditConfig::shared(),
"isolated" => AuditConfig::isolated(),
// Backwards compatibility aliases
"ci" => AuditConfig::isolated(),
"production" => AuditConfig::shared(),
_ => {
return Err(anyhow!(
"Invalid mode: {}. Use 'shared' or 'isolated'",
mode
))
}
};
// Audit needs to create events to test the relay, so disable read-only mode
config.read_only = false;
// Derive relay_domain from relay URL (e.g., "ws://localhost:8081" -> "localhost:8081")
let relay_domain = relay
.replace("ws://", "")
.replace("wss://", "")
.trim_end_matches('/')
.to_string();
println!("🔍 GRASP Audit Tool");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("Relay: {}", relay);
println!("Mode: {}", mode);
println!("Spec: {}", spec);
println!("Run ID: {}", config.run_id);
if let Some(ref dir) = git_data_dir {
println!("Git Dir: {}", dir.display());
}
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
println!("Connecting to relay...");
let client = AuditClient::new(&relay, config)
.await
.map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
if !client.is_connected().await {
return Err(anyhow!("Could not establish connection to relay"));
}
println!("✓ Connected\n");
let results = match spec.as_str() {
"nip01-smoke" => {
println!("Running NIP-01 smoke tests...\n");
specs::Nip01SmokeTests::run_all(&client).await
}
"nip11" => {
println!("Running NIP-11 document tests...\n");
specs::Nip11DocumentTests::run_all(&client).await
}
"event-acceptance" => {
println!("Running event acceptance policy tests...\n");
specs::EventAcceptancePolicyTests::run_all(&client).await
}
"cors" => {
println!("Running CORS tests...\n");
specs::CorsTests::run_all(&client, &relay_domain).await
}
"git-clone" => {
println!("Running Git clone tests...\n");
specs::GitCloneTests::run_all(&client, &relay_domain).await
}
"git-filter" => {
println!("Running Git filter capability tests...\n");
specs::GitFilterTests::run_all(&client, &relay_domain).await
}
"push-auth" => {
println!("Running push authorization tests...\n");
specs::PushAuthorizationTests::run_all(&client, &relay_domain).await
}
"repo-creation" => {
println!("Running repository creation tests...\n");
specs::RepositoryCreationTests::run_all(&client, &relay_domain).await
}
"purgatory" => {
println!("Running purgatory tests...\n");
specs::PurgatoryTests::run_all(&client).await
}
"all" => {
println!("Running all tests...\n");
let mut all_results = AuditResult::new("All GRASP-01 Tests");
// NIP-01 smoke tests (stateless - no shared fixture dependencies)
println!(" → NIP-01 smoke tests...");
let nip01_results = specs::Nip01SmokeTests::run_all(&client).await;
all_results.merge(nip01_results);
// NIP-11 document tests (stateless)
println!(" → NIP-11 document tests...");
let nip11_results = specs::Nip11DocumentTests::run_all(&client).await;
all_results.merge(nip11_results);
// CORS tests (stateless HTTP checks)
println!(" → CORS tests...");
let cors_results = specs::CorsTests::run_all(&client, &relay_domain).await;
all_results.merge(cors_results);
// Repository creation tests (uses ValidRepoSent only - no state events)
println!(" → Repository creation tests...");
let repo_results = specs::RepositoryCreationTests::run_all(&client, &relay_domain).await;
all_results.merge(repo_results);
// Git clone tests (uses ValidRepoSent only - no state events)
println!(" → Git clone tests...");
let clone_results = specs::GitCloneTests::run_all(&client, &relay_domain).await;
all_results.merge(clone_results);
// Git filter capability tests (uses ValidRepoSent only - no state events)
println!(" → Git filter capability tests...");
let filter_results = specs::GitFilterTests::run_all(&client, &relay_domain).await;
all_results.merge(filter_results);
// Event acceptance policy tests (uses ValidRepoServed - no extra state events)
println!(" → Event acceptance policy tests...");
let event_results = specs::EventAcceptancePolicyTests::run_all(&client).await;
all_results.merge(event_results);
// Purgatory tests MUST run before push-auth.
// Push-auth sends new replaceable state events (kind 30618) for the same
// repo_id as OwnerStateDataPushed (e.g. test_head_set_after_git_push_with_required_oids
// sends a develop1 state event that displaces the original). If purgatory ran
// after push-auth, is_event_on_relay(original_id) would return false because
// the original state event has been replaced on the relay.
println!(" → Purgatory tests...");
let purgatory_results = specs::PurgatoryTests::run_all(&client).await;
all_results.merge(purgatory_results);
// Push authorization tests (mutates shared state - must run last among git specs)
println!(" → Push authorization tests...");
let push_results = specs::PushAuthorizationTests::run_all(&client, &relay_domain).await;
all_results.merge(push_results);
println!();
all_results
}
_ => {
return Err(anyhow!(
"Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'git-filter', 'push-auth', 'repo-creation', 'purgatory', or 'all'",
spec
))
}
};
results.print_report();
if !results.all_passed() {
println!("❌ Some tests failed");
std::process::exit(1);
} else {
println!("✅ All tests passed!");
}
}
}
Ok(())
}
|