upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src/specs/grasp01/push_authorization.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-26 05:45:47 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-26 07:38:58 +0000
commit30411a938d072a59d68815c975735d40366ad874 (patch)
treef802d1bf9f9959105d2d18af81c528722fa7a675 /grasp-audit/src/specs/grasp01/push_authorization.rs
parenta005132ab806b7177d4eb3e3306914841704ffec (diff)
feat: push authorization from state event
Diffstat (limited to 'grasp-audit/src/specs/grasp01/push_authorization.rs')
-rw-r--r--grasp-audit/src/specs/grasp01/push_authorization.rs551
1 files changed, 551 insertions, 0 deletions
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs
new file mode 100644
index 0000000..974ccd4
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/push_authorization.rs
@@ -0,0 +1,551 @@
1//! GRASP-01 Push Authorization Tests
2//!
3//! Tests that verify push authorization works correctly according to GRASP-01:
4//! "MUST accept pushes via this service that match the latest repo state announcement
5//! on the relay, respecting the recursive maintainer set."
6//!
7//! ## Test Coverage
8//!
9//! - Push authorized when state event matches commit being pushed
10//! - Push rejected when no state event exists
11//! - Push rejected when state event has different commit
12//!
13//! ## Running Tests
14//!
15//! ```bash
16//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
17//! ```
18
19use crate::{AuditClient, FixtureKind, TestContext, TestResult};
20use nostr_sdk::prelude::*;
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25/// Test suite for Push Authorization operations
26pub struct PushAuthorizationTests;
27
28/// Helper to clone a repository and return the path
29fn clone_repo(
30 relay_domain: &str,
31 npub: &str,
32 repo_id: &str,
33) -> Result<std::path::PathBuf, String> {
34 let temp_base = std::env::temp_dir();
35 let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4());
36 let clone_path = temp_base.join(&clone_dir_name);
37 let _ = fs::remove_dir_all(&clone_path);
38
39 let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
40 let output = Command::new("git")
41 .args(["clone", &clone_url, clone_path.to_str().unwrap()])
42 .env("GIT_TERMINAL_PROMPT", "0")
43 .output()
44 .map_err(|e| format!("Failed to execute git clone: {}", e))?;
45
46 if !output.status.success() {
47 let stderr = String::from_utf8_lossy(&output.stderr);
48 return Err(format!("Git clone failed: {}", stderr));
49 }
50
51 // Configure git user
52 let _ = Command::new("git")
53 .args(["config", "user.email", "test@grasp-audit.local"])
54 .current_dir(&clone_path)
55 .output();
56 let _ = Command::new("git")
57 .args(["config", "user.name", "GRASP Audit Test"])
58 .current_dir(&clone_path)
59 .output();
60
61 Ok(clone_path)
62}
63
64/// Helper to create a commit and return the hash
65fn create_commit(clone_path: &Path, message: &str) -> Result<String, String> {
66 let test_file = clone_path.join(format!("test-{}.txt", uuid::Uuid::new_v4()));
67 fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?;
68
69 let filename = test_file.file_name().unwrap().to_str().unwrap();
70 let output = Command::new("git")
71 .args(["add", filename])
72 .current_dir(clone_path)
73 .output()
74 .map_err(|e| format!("Git add failed: {}", e))?;
75
76 if !output.status.success() {
77 return Err("Git add failed".to_string());
78 }
79
80 let output = Command::new("git")
81 .args(["commit", "-m", message])
82 .current_dir(clone_path)
83 .output()
84 .map_err(|e| format!("Git commit failed: {}", e))?;
85
86 if !output.status.success() {
87 return Err("Git commit failed".to_string());
88 }
89
90 let output = Command::new("git")
91 .args(["rev-parse", "HEAD"])
92 .current_dir(clone_path)
93 .output()
94 .map_err(|e| format!("Git rev-parse failed: {}", e))?;
95
96 if !output.status.success() {
97 return Err("Failed to get commit hash".to_string());
98 }
99
100 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
101}
102
103/// Helper to create a deterministic commit (for fixtures)
104/// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash
105pub fn create_deterministic_commit(clone_path: &Path, message: &str) -> Result<String, String> {
106 let test_file = clone_path.join("test.txt");
107 fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?;
108
109 let output = Command::new("git")
110 .args(["add", "test.txt"])
111 .current_dir(clone_path)
112 .output()
113 .map_err(|e| format!("Git add failed: {}", e))?;
114
115 if !output.status.success() {
116 return Err("Git add failed".to_string());
117 }
118
119 // Create deterministic commit with fixed dates and GPG disabled
120 let output = Command::new("git")
121 .args([
122 "-c", "commit.gpgsign=false",
123 "commit",
124 "-m", message,
125 ])
126 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z")
127 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z")
128 .current_dir(clone_path)
129 .output()
130 .map_err(|e| format!("Git commit failed: {}", e))?;
131
132 if !output.status.success() {
133 let stderr = String::from_utf8_lossy(&output.stderr);
134 return Err(format!("Git commit failed: {}", stderr));
135 }
136
137 let output = Command::new("git")
138 .args(["rev-parse", "HEAD"])
139 .current_dir(clone_path)
140 .output()
141 .map_err(|e| format!("Git rev-parse failed: {}", e))?;
142
143 if !output.status.success() {
144 return Err("Failed to get commit hash".to_string());
145 }
146
147 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
148}
149
150/// Repository setup with deterministic commit
151/// This struct holds all the data needed for push authorization tests
152pub struct RepoSetup {
153 pub clone_path: PathBuf,
154 pub repo_id: String,
155 pub npub: String,
156 pub commit_hash: String,
157}
158
159impl Drop for RepoSetup {
160 fn drop(&mut self) {
161 let _ = fs::remove_dir_all(&self.clone_path);
162 }
163}
164
165/// Helper function to set up a repository with deterministic commit
166///
167/// This performs all the common setup steps needed for push authorization tests:
168/// 1. Gets RepoState fixture (repo announcement + state event with deterministic commit)
169/// 2. Extracts repo_id and npub
170/// 3. Verifies repo exists on disk
171/// 4. Clones the repository
172/// 5. Creates deterministic commit locally
173/// 6. Verifies commit hash matches expected
174/// 7. Creates and checks out main branch
175/// 8. Pushes the commit so the grasp server has the state in the state event
176///
177/// Returns RepoSetup which auto-cleans up the clone_path on drop
178pub async fn setup_repo_with_deterministic_commit(
179 client: &AuditClient,
180 git_data_dir: &Path,
181 relay_domain: &str,
182) -> Result<RepoSetup, String> {
183 use crate::DETERMINISTIC_COMMIT_HASH;
184
185 let ctx = TestContext::new(client);
186
187 // Get RepoState fixture (includes repo announcement and state event with deterministic commit)
188 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
189 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
190
191 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
192
193 // Extract repo_id from state event
194 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
195 .and_then(|t| t.content())
196 .ok_or("Missing repo_id")?
197 .to_string();
198 let npub = state_event.pubkey.to_bech32()
199 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
200
201 // Verify repo exists
202 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
203 if !repo_path.exists() {
204 return Err(format!("Repo not found: {}", repo_path.display()));
205 }
206
207 // Clone repo
208 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
209
210 // Create deterministic commit locally (this will be the root commit with no parent)
211 let commit_hash = create_deterministic_commit(&clone_path, "Initial commit")
212 .map_err(|e| {
213 let _ = fs::remove_dir_all(&clone_path);
214 e
215 })?;
216
217 // Verify commit hash matches expected deterministic hash
218 if commit_hash != DETERMINISTIC_COMMIT_HASH {
219 let _ = fs::remove_dir_all(&clone_path);
220 return Err(format!(
221 "Commit hash mismatch: got {}, expected {}",
222 commit_hash, DETERMINISTIC_COMMIT_HASH
223 ));
224 }
225
226 // Create main branch pointing to our deterministic commit
227 let branch_output = Command::new("git")
228 .args(["branch", "main"])
229 .current_dir(&clone_path)
230 .output()
231 .map_err(|e| {
232 let _ = fs::remove_dir_all(&clone_path);
233 format!("Failed to create main branch: {}", e)
234 })?;
235
236 if !branch_output.status.success() {
237 let _ = fs::remove_dir_all(&clone_path);
238 return Err(format!(
239 "Failed to create main branch: {}",
240 String::from_utf8_lossy(&branch_output.stderr)
241 ));
242 }
243
244 // Checkout main branch
245 let checkout_output = Command::new("git")
246 .args(["checkout", "main"])
247 .current_dir(&clone_path)
248 .output()
249 .map_err(|e| {
250 let _ = fs::remove_dir_all(&clone_path);
251 format!("Failed to checkout main branch: {}", e)
252 })?;
253
254 if !checkout_output.status.success() {
255 let _ = fs::remove_dir_all(&clone_path);
256 return Err(format!(
257 "Failed to checkout main branch: {}",
258 String::from_utf8_lossy(&checkout_output.stderr)
259 ));
260 }
261
262 // Push the commit to the server so the bare repo matches the state event
263 let push_output = Command::new("git")
264 .args(["push", "origin", "main"])
265 .current_dir(&clone_path)
266 .env("GIT_TERMINAL_PROMPT", "0")
267 .output()
268 .map_err(|e| {
269 let _ = fs::remove_dir_all(&clone_path);
270 format!("Failed to push to server: {}", e)
271 })?;
272
273 if !push_output.status.success() {
274 let _ = fs::remove_dir_all(&clone_path);
275 return Err(format!(
276 "Failed to push to server: {}",
277 String::from_utf8_lossy(&push_output.stderr)
278 ));
279 }
280
281 Ok(RepoSetup {
282 clone_path,
283 repo_id,
284 npub,
285 commit_hash,
286 })
287}
288
289/// Helper to attempt a push and return success/failure
290fn try_push(clone_path: &Path) -> Result<bool, String> {
291 let output = Command::new("git")
292 .args(["push", "origin", "main"])
293 .current_dir(clone_path)
294 .env("GIT_TERMINAL_PROMPT", "0")
295 .output()
296 .map_err(|e| format!("Failed to execute git push: {}", e))?;
297
298 Ok(output.status.success())
299}
300
301impl PushAuthorizationTests {
302 /// Test that push is authorized when state event matches the commit
303 ///
304 /// GRASP-01: "MUST accept pushes via this service that match the latest
305 /// repo state announcement on the relay"
306 pub async fn test_push_authorized_by_owner_state(
307 client: &AuditClient,
308 git_data_dir: &Path,
309 relay_domain: &str,
310 ) -> TestResult {
311 let test_name = "test_push_authorized_by_owner_state";
312
313 // this setup is exactly what we are testing
314 match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await {
315 Ok(_) => {
316 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").pass()
317 },
318 Err(e) => {
319 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state")
320 .fail(&format!("Failed: {}", e))
321 }
322 };
323 }
324
325 /// Test that push is rejected when no state event exists
326 pub async fn test_push_rejected_without_state_event(
327 client: &AuditClient,
328 git_data_dir: &Path,
329 relay_domain: &str,
330 ) -> TestResult {
331 let test_name = "test_push_rejected_without_state_event";
332 let ctx = TestContext::new(client);
333
334 // Create repository (no state event)
335 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
336 Ok(r) => r,
337 Err(e) => {
338 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event")
339 .fail(&format!("Failed to create repo: {}", e))
340 }
341 };
342
343 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
344
345 let repo_id = repo.tags.iter().find(|t| t.kind() == TagKind::d())
346 .and_then(|t| t.content()).unwrap().to_string();
347 let npub = repo.pubkey.to_bech32().unwrap();
348
349 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
350 if !repo_path.exists() {
351 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event")
352 .fail(&format!("Repo not found: {}", repo_path.display()));
353 }
354
355 // Clone and create commit
356 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
357 Ok(p) => p,
358 Err(e) => return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e),
359 };
360 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
361
362 if let Err(e) = create_commit(&clone_path, "Unauthorized commit") {
363 cleanup();
364 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e);
365 }
366
367 // Do NOT publish state event - push should be rejected
368 let push_result = try_push(&clone_path);
369 cleanup();
370
371 match push_result {
372 Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass(),
373 Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail("Push accepted but should be rejected"),
374 Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e),
375 }
376 }
377
378 /// Test that push is rejected when commit doesn't match state event
379 ///
380 /// This test verifies that the relay enforces state event authorization.
381 /// The state event (from fixture) points to the deterministic commit which is
382 /// already on the server. We create a new commit locally and try to push it.
383 /// The push should be rejected because the new commit doesn't match what the
384 /// state event announces.
385 pub async fn test_push_rejected_wrong_commit(
386 client: &AuditClient,
387 git_data_dir: &Path,
388 relay_domain: &str,
389 ) -> TestResult {
390 let test_name = "test_push_rejected_wrong_commit";
391
392 // Set up repository with deterministic commit
393 // This creates a state event pointing to DETERMINISTIC_COMMIT_HASH and pushes that commit
394 let setup = match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await {
395 Ok(s) => s,
396 Err(e) => {
397 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event")
398 .fail(&format!("Setup failed: {}", e))
399 }
400 };
401
402 // Create a new commit locally - this is NOT announced in any state event
403 let new_commit = match create_commit(&setup.clone_path, "Unauthorized commit") {
404 Ok(h) => h,
405 Err(e) => {
406 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event")
407 .fail(&format!("Failed to create commit: {}", e))
408 }
409 };
410
411 // Try to push the new commit
412 // This should be REJECTED because:
413 // - The state event still points to the deterministic commit (setup.commit_hash)
414 // - We're trying to push new_commit which is different
415 // - The relay MUST reject pushes that don't match the announced state
416 let push_result = try_push(&setup.clone_path);
417
418 match push_result {
419 Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event").pass(),
420 Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event")
421 .fail(&format!(
422 "Push accepted but should be rejected. State event points to {}, but pushed {}",
423 setup.commit_hash, new_commit
424 )),
425 Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event").fail(&e),
426 }
427 }
428
429 /// Test recursive maintainer authorization
430 ///
431 /// GRASP-01: "respecting the recursive maintainer set"
432 pub async fn test_recursive_maintainer_authorization(
433 _client: &AuditClient,
434 _git_data_dir: &Path,
435 _relay_domain: &str,
436 ) -> TestResult {
437 let test_name = "test_recursive_maintainer_authorization";
438
439 // This test requires two separate clients (owner and maintainer)
440 // For now, return not implemented
441 TestResult::new(test_name, "GRASP-01", "Maintainer can authorize pushes")
442 .fail("Not implemented: requires multiple client support")
443 }
444
445 /// Test that latest state event is used for authorization
446 pub async fn test_latest_state_event_used(
447 _client: &AuditClient,
448 _git_data_dir: &Path,
449 _relay_domain: &str,
450 ) -> TestResult {
451 let test_name = "test_latest_state_event_used";
452
453 // This test requires publishing multiple state events with timestamps
454 // and verifying the latest one is used
455 TestResult::new(test_name, "GRASP-01", "Latest state event takes precedence")
456 .fail("Not implemented: requires timestamp manipulation")
457 }
458
459 /// Test that non-maintainer state event is ignored
460 ///
461 /// This test verifies that the relay ignores state events from non-maintainers.
462 /// We set up a valid repo, then create a rogue state event signed by a different
463 /// keypair (not the repo maintainer) that announces a different commit. The push
464 /// should be rejected because the rogue state event is not authorized.
465 pub async fn test_non_maintainer_state_rejected(
466 client: &AuditClient,
467 git_data_dir: &Path,
468 relay_domain: &str,
469 ) -> TestResult {
470 let test_name = "test_non_maintainer_state_rejected";
471
472 // Set up repository with deterministic commit (signed by maintainer)
473 let setup = match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await {
474 Ok(s) => s,
475 Err(e) => {
476 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
477 .fail(&format!("Setup failed: {}", e))
478 }
479 };
480
481 // Create a new commit locally that we want to push
482 let new_commit = match create_commit(&setup.clone_path, "New commit to push") {
483 Ok(h) => h,
484 Err(e) => {
485 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
486 .fail(&format!("Failed to create commit: {}", e))
487 }
488 };
489
490 // Create a rogue keypair (NOT the maintainer)
491 let rogue_keys = Keys::generate();
492
493 // Create a rogue state event announcing the new commit
494 // This event has the correct repo_id but is signed by a non-maintainer
495 let rogue_state = match client
496 .event_builder(Kind::Custom(30618), "")
497 .tag(Tag::identifier(&setup.repo_id))
498 .tag(Tag::custom(
499 TagKind::custom("refs/heads/main"),
500 vec![new_commit.clone()],
501 ))
502 .build(&rogue_keys)
503 {
504 Ok(e) => e,
505 Err(e) => {
506 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
507 .fail(&format!("Failed to build rogue state event: {}", e))
508 }
509 };
510
511 // Send the rogue state event using the raw client to bypass AuditClient's key check
512 if let Err(e) = client.client().send_event(&rogue_state).await {
513 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
514 .fail(&format!("Failed to send rogue state event: {}", e));
515 }
516
517 // Wait for event to propagate
518 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
519
520 // Try to push the new commit
521 // This should be REJECTED because:
522 // - The rogue state event announces new_commit
523 // - But the rogue state event is NOT signed by the maintainer
524 // - The relay should ignore the rogue state event
525 // - The valid state event (from setup) still points to the deterministic commit
526 // - Therefore pushing new_commit should fail
527 let push_result = try_push(&setup.clone_path);
528
529 match push_result {
530 Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(),
531 Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
532 .fail(&format!(
533 "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \
534 a state event announcing commit {}, but the push was accepted. The relay should \
535 only accept state events from maintainers (pubkey: {}).",
536 rogue_keys.public_key(),
537 new_commit,
538 client.public_key()
539 )),
540 Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e),
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 #[test]
548 fn test_module_exists() {
549 assert!(true);
550 }
551} \ No newline at end of file