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-26 15:36:12 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-26 17:16:24 +0000
commit734d255efaa26bcb18b29d655bf30f8affb3a852 (patch)
treeb0d5b72e38bd4ceb6d35334741708f2a774a4994 /grasp-audit/src/specs/grasp01
parent158d3f0722e731f2b534951069c322c5cbb5a721 (diff)
test: use fixtures in push tests
Diffstat (limited to 'grasp-audit/src/specs/grasp01')
-rw-r--r--grasp-audit/src/specs/grasp01/git_clone.rs2
-rw-r--r--grasp-audit/src/specs/grasp01/mod.rs2
-rw-r--r--grasp-audit/src/specs/grasp01/push_authorization.rs1560
3 files changed, 420 insertions, 1144 deletions
diff --git a/grasp-audit/src/specs/grasp01/git_clone.rs b/grasp-audit/src/specs/grasp01/git_clone.rs
index f85f94a..da60f26 100644
--- a/grasp-audit/src/specs/grasp01/git_clone.rs
+++ b/grasp-audit/src/specs/grasp01/git_clone.rs
@@ -267,7 +267,7 @@ impl GitCloneTests {
267 267
268#[cfg(test)] 268#[cfg(test)]
269mod tests { 269mod tests {
270 use super::*; 270
271 271
272 #[test] 272 #[test]
273 fn test_module_exists() { 273 fn test_module_exists() {
diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs
index 0d0bd9c..5ce5eca 100644
--- a/grasp-audit/src/specs/grasp01/mod.rs
+++ b/grasp-audit/src/specs/grasp01/mod.rs
@@ -13,5 +13,5 @@ pub use event_acceptance_policy::EventAcceptancePolicyTests;
13pub use git_clone::GitCloneTests; 13pub use git_clone::GitCloneTests;
14pub use nip01_smoke::Nip01SmokeTests; 14pub use nip01_smoke::Nip01SmokeTests;
15pub use nip11_document::Nip11DocumentTests; 15pub use nip11_document::Nip11DocumentTests;
16pub use push_authorization::PushAuthorizationTests; 16pub use push_authorization::{CommitVariant, PushAuthorizationTests};
17pub use repository_creation::RepositoryCreationTests; 17pub use repository_creation::RepositoryCreationTests;
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs
index 5545b1a..cba9e69 100644
--- a/grasp-audit/src/specs/grasp01/push_authorization.rs
+++ b/grasp-audit/src/specs/grasp01/push_authorization.rs
@@ -100,11 +100,51 @@ fn create_commit(clone_path: &Path, message: &str) -> Result<String, String> {
100 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) 100 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
101} 101}
102 102
103/// Variant of deterministic commit for different pubkey types
104/// Each variant produces a different but reproducible commit hash
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum CommitVariant {
107 /// Main pubkey variant - uses "Initial commit" content
108 Owner,
109 /// Maintainer pubkey variant - uses "Maintainer initial commit" content
110 Maintainer,
111 /// Recursive maintainer pubkey variant - uses "Recursive maintainer initial commit" content
112 RecursiveMaintainer,
113}
114
115impl CommitVariant {
116 /// Get the file content for this variant
117 pub fn file_content(&self) -> &'static str {
118 match self {
119 CommitVariant::Owner => "Initial commit",
120 CommitVariant::Maintainer => "Maintainer initial commit",
121 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
122 }
123 }
124
125 /// Get the commit message for this variant
126 pub fn commit_message(&self) -> &'static str {
127 match self {
128 CommitVariant::Owner => "Initial commit",
129 CommitVariant::Maintainer => "Maintainer initial commit",
130 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
131 }
132 }
133}
134
103/// Helper to create a deterministic commit (for fixtures) 135/// Helper to create a deterministic commit (for fixtures)
104/// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash 136/// 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> { 137///
138/// The variant parameter allows different commit hashes for different pubkey types:
139/// - Owner: uses the original DETERMINISTIC_COMMIT_HASH
140/// - Maintainer: uses MAINTAINER_DETERMINISTIC_COMMIT_HASH
141/// - RecursiveMaintainer: uses RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
142pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result<String, String> {
106 let test_file = clone_path.join("test.txt"); 143 let test_file = clone_path.join("test.txt");
107 fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?; 144 let content = variant.file_content();
145 let message = variant.commit_message();
146
147 fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?;
108 148
109 let output = Command::new("git") 149 let output = Command::new("git")
110 .args(["add", "test.txt"]) 150 .args(["add", "test.txt"])
@@ -147,6 +187,14 @@ pub fn create_deterministic_commit(clone_path: &Path, message: &str) -> Result<S
147 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) 187 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
148} 188}
149 189
190/// Helper to create a deterministic commit (for fixtures) - uses Owner variant
191/// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash
192pub fn create_deterministic_commit(clone_path: &Path, _message: &str) -> Result<String, String> {
193 // Note: message parameter is ignored for backwards compatibility
194 // The Owner variant always uses "Initial commit"
195 create_deterministic_commit_with_variant(clone_path, CommitVariant::Owner)
196}
197
150/// Repository setup with deterministic commit 198/// Repository setup with deterministic commit
151/// This struct holds all the data needed for push authorization tests 199/// This struct holds all the data needed for push authorization tests
152pub struct RepoSetup { 200pub struct RepoSetup {
@@ -286,6 +334,282 @@ pub async fn setup_repo_with_deterministic_commit(
286 }) 334 })
287} 335}
288 336
337/// Helper function to set up a maintainer repository with deterministic commit (state only)
338///
339/// This performs all the common setup steps needed for maintainer push authorization tests:
340/// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit)
341/// 2. Gets MaintainerState fixture (maintainer's state event ONLY - no announcement)
342/// 3. Extracts repo_id and owner npub
343/// 4. Verifies repo exists on disk
344/// 5. Clones the repository using owner's npub
345/// 6. Creates maintainer deterministic commit locally
346/// 7. Verifies commit hash matches expected
347/// 8. Creates and checks out main branch
348/// 9. Pushes the commit so the grasp server has the state in the state event
349///
350/// Note: This does NOT publish a maintainer announcement. For tests that need the
351/// maintainer announcement (like recursive maintainer tests), use setup_repo_for_recursive_maintainer
352/// which publishes MaintainerAnnouncement separately.
353///
354/// Returns RepoSetup which auto-cleans up the clone_path on drop
355pub async fn setup_repo_for_maintainer(
356 client: &AuditClient,
357 git_data_dir: &Path,
358 relay_domain: &str,
359) -> Result<RepoSetup, String> {
360 use crate::MAINTAINER_DETERMINISTIC_COMMIT_HASH;
361
362 let ctx = TestContext::new(client);
363
364 // Get RepoState fixture (includes owner's repo announcement and state event with owner's deterministic commit)
365 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
366 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
367
368 // Get MaintainerState fixture ONLY (no announcement - tests state-only authorization)
369 let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await
370 .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?;
371
372 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
373
374 // Extract repo_id from state event
375 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
376 .and_then(|t| t.content())
377 .ok_or("Missing repo_id")?
378 .to_string();
379
380 // The npub is from the owner keys (the signer of the state event)
381 let npub = state_event.pubkey.to_bech32()
382 .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?;
383
384 // Verify repo exists
385 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
386 if !repo_path.exists() {
387 return Err(format!("Owner repo not found: {}", repo_path.display()));
388 }
389
390 // Clone repo using owner's npub
391 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
392
393 // Create maintainer deterministic commit locally (this will be the root commit with no parent)
394 let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer)
395 .map_err(|e| {
396 let _ = fs::remove_dir_all(&clone_path);
397 e
398 })?;
399
400 // Verify commit hash matches expected maintainer deterministic hash
401 if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH {
402 let _ = fs::remove_dir_all(&clone_path);
403 return Err(format!(
404 "Maintainer commit hash mismatch: got {}, expected {}",
405 commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH
406 ));
407 }
408
409 // Create main branch pointing to our deterministic commit
410 let branch_output = Command::new("git")
411 .args(["branch", "main"])
412 .current_dir(&clone_path)
413 .output()
414 .map_err(|e| {
415 let _ = fs::remove_dir_all(&clone_path);
416 format!("Failed to create main branch: {}", e)
417 })?;
418
419 if !branch_output.status.success() {
420 let _ = fs::remove_dir_all(&clone_path);
421 return Err(format!(
422 "Failed to create main branch: {}",
423 String::from_utf8_lossy(&branch_output.stderr)
424 ));
425 }
426
427 // Checkout main branch
428 let checkout_output = Command::new("git")
429 .args(["checkout", "main"])
430 .current_dir(&clone_path)
431 .output()
432 .map_err(|e| {
433 let _ = fs::remove_dir_all(&clone_path);
434 format!("Failed to checkout main branch: {}", e)
435 })?;
436
437 if !checkout_output.status.success() {
438 let _ = fs::remove_dir_all(&clone_path);
439 return Err(format!(
440 "Failed to checkout main branch: {}",
441 String::from_utf8_lossy(&checkout_output.stderr)
442 ));
443 }
444
445 // Push the commit to the server so the bare repo matches the state event
446 let push_output = Command::new("git")
447 .args(["push", "origin", "main"])
448 .current_dir(&clone_path)
449 .env("GIT_TERMINAL_PROMPT", "0")
450 .output()
451 .map_err(|e| {
452 let _ = fs::remove_dir_all(&clone_path);
453 format!("Failed to push to server: {}", e)
454 })?;
455
456 if !push_output.status.success() {
457 let _ = fs::remove_dir_all(&clone_path);
458 return Err(format!(
459 "Failed to push to server: {}",
460 String::from_utf8_lossy(&push_output.stderr)
461 ));
462 }
463
464 Ok(RepoSetup {
465 clone_path,
466 repo_id,
467 npub,
468 commit_hash,
469 })
470}
471
472/// Helper function to set up a recursive maintainer repository with deterministic commit
473///
474/// This performs all the common setup steps needed for recursive maintainer push authorization tests:
475/// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit)
476/// 2. Gets MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag)
477/// 3. Gets MaintainerState fixture (maintainer's state event)
478/// 4. Gets RecursiveMaintainerRepoAndState fixture (recursive maintainer's repo - completes 3-level chain)
479/// 5. Extracts repo_id and owner npub
480/// 6. Verifies repo exists on disk
481/// 7. Clones the repository using owner's npub
482/// 8. Creates recursive maintainer deterministic commit locally
483/// 9. Verifies commit hash matches expected
484/// 10. Creates and checks out main branch
485/// 11. Pushes the commit so the grasp server has the state in the state event
486///
487/// Returns RepoSetup which auto-cleans up the clone_path on drop
488pub async fn setup_repo_for_recursive_maintainer(
489 client: &AuditClient,
490 git_data_dir: &Path,
491 relay_domain: &str,
492) -> Result<RepoSetup, String> {
493 use crate::RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH;
494
495 let ctx = TestContext::new(client);
496
497 // Get RepoState fixture (includes owner's repo announcement and state event)
498 let state_event = ctx.get_fixture(FixtureKind::RepoState).await
499 .map_err(|e| format!("Failed to create repo state fixture: {}", e))?;
500
501 // Get MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag)
502 let _maintainer_announcement = ctx.get_fixture(FixtureKind::MaintainerAnnouncement).await
503 .map_err(|e| format!("Failed to create maintainer announcement fixture: {}", e))?;
504
505 // Get MaintainerState fixture (maintainer's state event)
506 let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await
507 .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?;
508
509 // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain)
510 let _recursive_maintainer_state = ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await
511 .map_err(|e| format!("Failed to create recursive maintainer repo state fixture: {}", e))?;
512
513 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
514
515 // Extract repo_id from owner's state event
516 let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d())
517 .and_then(|t| t.content())
518 .ok_or("Missing repo_id")?
519 .to_string();
520
521 // The npub is from the owner keys (the signer of the state event)
522 let npub = state_event.pubkey.to_bech32()
523 .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?;
524
525 // Verify repo exists
526 let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
527 if !repo_path.exists() {
528 return Err(format!("Owner repo not found: {}", repo_path.display()));
529 }
530
531 // Clone repo using owner's npub
532 let clone_path = clone_repo(relay_domain, &npub, &repo_id)?;
533
534 // Create recursive maintainer deterministic commit locally (this will be the root commit with no parent)
535 let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer)
536 .map_err(|e| {
537 let _ = fs::remove_dir_all(&clone_path);
538 e
539 })?;
540
541 // Verify commit hash matches expected recursive maintainer deterministic hash
542 if commit_hash != RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH {
543 let _ = fs::remove_dir_all(&clone_path);
544 return Err(format!(
545 "Recursive maintainer commit hash mismatch: got {}, expected {}",
546 commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
547 ));
548 }
549
550 // Create main branch pointing to our deterministic commit
551 let branch_output = Command::new("git")
552 .args(["branch", "main"])
553 .current_dir(&clone_path)
554 .output()
555 .map_err(|e| {
556 let _ = fs::remove_dir_all(&clone_path);
557 format!("Failed to create main branch: {}", e)
558 })?;
559
560 if !branch_output.status.success() {
561 let _ = fs::remove_dir_all(&clone_path);
562 return Err(format!(
563 "Failed to create main branch: {}",
564 String::from_utf8_lossy(&branch_output.stderr)
565 ));
566 }
567
568 // Checkout main branch
569 let checkout_output = Command::new("git")
570 .args(["checkout", "main"])
571 .current_dir(&clone_path)
572 .output()
573 .map_err(|e| {
574 let _ = fs::remove_dir_all(&clone_path);
575 format!("Failed to checkout main branch: {}", e)
576 })?;
577
578 if !checkout_output.status.success() {
579 let _ = fs::remove_dir_all(&clone_path);
580 return Err(format!(
581 "Failed to checkout main branch: {}",
582 String::from_utf8_lossy(&checkout_output.stderr)
583 ));
584 }
585
586 // Push the commit to the server so the bare repo matches the state event
587 let push_output = Command::new("git")
588 .args(["push", "origin", "main"])
589 .current_dir(&clone_path)
590 .env("GIT_TERMINAL_PROMPT", "0")
591 .output()
592 .map_err(|e| {
593 let _ = fs::remove_dir_all(&clone_path);
594 format!("Failed to push to server: {}", e)
595 })?;
596
597 if !push_output.status.success() {
598 let _ = fs::remove_dir_all(&clone_path);
599 return Err(format!(
600 "Failed to push to server: {}",
601 String::from_utf8_lossy(&push_output.stderr)
602 ));
603 }
604
605 Ok(RepoSetup {
606 clone_path,
607 repo_id,
608 npub,
609 commit_hash,
610 })
611}
612
289/// Helper to attempt a push and return success/failure 613/// Helper to attempt a push and return success/failure
290fn try_push(clone_path: &Path) -> Result<bool, String> { 614fn try_push(clone_path: &Path) -> Result<bool, String> {
291 let output = Command::new("git") 615 let output = Command::new("git")
@@ -426,555 +750,65 @@ impl PushAuthorizationTests {
426 } 750 }
427 } 751 }
428 752
429 /// Test that latest state event is used for authorization 753 /// Test push authorized by maintainer state event only (no announcement)
430 ///
431 /// GRASP-01 requires that the relay use the LATEST state event (by created_at
432 /// timestamp) when determining push authorization. This test verifies that
433 /// a newer state event takes precedence over an older one.
434 ///
435 /// Scenario:
436 /// 1. Owner creates repo with maintainer
437 /// 2. Owner publishes state event for commit_a at t=100 (older)
438 /// 3. Maintainer publishes state event for commit_b at t=200 (newer)
439 /// 4. Push commit_b should be ACCEPTED (newer timestamp wins)
440 /// 5. Push commit_a should be REJECTED (older state event superseded)
441 pub async fn test_latest_state_event_used(
442 client: &AuditClient,
443 git_data_dir: &Path,
444 relay_domain: &str,
445 ) -> TestResult {
446 let test_name = "test_latest_state_event_used";
447 let description = "Latest state event takes precedence";
448
449 // 1. Generate maintainer keypair
450 let maintainer_keys = Keys::generate();
451 let maintainer_pubkey = maintainer_keys.public_key().to_hex();
452
453 // 2. Owner creates repo with maintainer
454 let repo_event = match client
455 .create_repo_announcement_with_maintainers(test_name, &[maintainer_pubkey.clone()])
456 .await
457 {
458 Ok(e) => e,
459 Err(e) => {
460 return TestResult::new(test_name, "GRASP-01", description)
461 .fail(&format!("Failed to create repo with maintainers: {}", e))
462 }
463 };
464
465 // Send the owner's repo event
466 if let Err(e) = client.send_event(repo_event.clone()).await {
467 return TestResult::new(test_name, "GRASP-01", description)
468 .fail(&format!("Failed to send owner repo event: {}", e));
469 }
470
471 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
472
473 // Extract repo details
474 let repo_id = match repo_event
475 .tags
476 .iter()
477 .find(|t| t.kind() == TagKind::d())
478 .and_then(|t| t.content())
479 {
480 Some(id) => id.to_string(),
481 None => {
482 return TestResult::new(test_name, "GRASP-01", description)
483 .fail("Repository event missing d tag")
484 }
485 };
486
487 // Get relay URL for maintainer's repo announcement
488 let relay_url = match client.relay_url().await {
489 Ok(u) => u,
490 Err(e) => {
491 return TestResult::new(test_name, "GRASP-01", description)
492 .fail(&format!("Failed to get relay URL: {}", e))
493 }
494 };
495 let http_url = relay_url
496 .replace("ws://", "http://")
497 .replace("wss://", "https://");
498 let maintainer_npub = match maintainer_keys.public_key().to_bech32() {
499 Ok(n) => n,
500 Err(e) => {
501 return TestResult::new(test_name, "GRASP-01", description)
502 .fail(&format!("Failed to convert maintainer pubkey to npub: {}", e))
503 }
504 };
505
506 // 3. Maintainer creates their own repo announcement (same d-tag)
507 let maintainer_repo_event = match client
508 .event_builder(
509 Kind::GitRepoAnnouncement,
510 format!("Maintainer's view of {} repository", test_name),
511 )
512 .tag(Tag::identifier(&repo_id))
513 .tag(Tag::custom(
514 TagKind::custom("name"),
515 vec![format!("{} Test Repository (Maintainer)", test_name)],
516 ))
517 .tag(Tag::custom(
518 TagKind::custom("clone"),
519 vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)],
520 ))
521 .tag(Tag::custom(
522 TagKind::custom("relays"),
523 vec![relay_url.clone()],
524 ))
525 .build(&maintainer_keys)
526 {
527 Ok(e) => e,
528 Err(e) => {
529 return TestResult::new(test_name, "GRASP-01", description)
530 .fail(&format!("Failed to build maintainer repo event: {}", e))
531 }
532 };
533
534 if let Err(e) = client.client().send_event(&maintainer_repo_event).await {
535 return TestResult::new(test_name, "GRASP-01", description)
536 .fail(&format!("Failed to send maintainer repo event: {}", e));
537 }
538
539 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
540
541 // Verify maintainer's repo was created
542 let maintainer_repo_path = git_data_dir
543 .join(&maintainer_npub)
544 .join(format!("{}.git", repo_id));
545 if !maintainer_repo_path.exists() {
546 return TestResult::new(test_name, "GRASP-01", description).fail(&format!(
547 "Maintainer repo not created at: {}",
548 maintainer_repo_path.display()
549 ));
550 }
551
552 // 4. Clone maintainer's repo
553 let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) {
554 Ok(p) => p,
555 Err(e) => {
556 return TestResult::new(test_name, "GRASP-01", description)
557 .fail(&format!("Failed to clone maintainer repo: {}", e))
558 }
559 };
560
561 // 5. Create first commit (commit_a) - this will be the one with OLDER timestamp
562 let commit_a = match create_commit(&clone_path, "Commit A - older state") {
563 Ok(h) => h,
564 Err(e) => {
565 let _ = fs::remove_dir_all(&clone_path);
566 return TestResult::new(test_name, "GRASP-01", description)
567 .fail(&format!("Failed to create commit_a: {}", e));
568 }
569 };
570
571 // 6. Create second commit (commit_b) - this will be the one with NEWER timestamp
572 let commit_b = match create_commit(&clone_path, "Commit B - newer state") {
573 Ok(h) => h,
574 Err(e) => {
575 let _ = fs::remove_dir_all(&clone_path);
576 return TestResult::new(test_name, "GRASP-01", description)
577 .fail(&format!("Failed to create commit_b: {}", e));
578 }
579 };
580
581 // 7. Calculate timestamps: older_timestamp (100 seconds ago) and newer_timestamp (now)
582 let base_time = Timestamp::now().as_u64();
583 let older_timestamp = Timestamp::from(base_time - 100); // 100 seconds ago
584 let newer_timestamp = Timestamp::from(base_time); // now
585
586 // 8. Owner publishes state event for commit_a at OLDER timestamp
587 let owner_state_event = match client
588 .event_builder(Kind::Custom(30618), "")
589 .tag(Tag::identifier(&repo_id))
590 .tag(Tag::custom(
591 TagKind::custom("refs/heads/main"),
592 vec![commit_a.clone()],
593 ))
594 .custom_time(older_timestamp)
595 .build(client.keys())
596 {
597 Ok(e) => e,
598 Err(e) => {
599 let _ = fs::remove_dir_all(&clone_path);
600 return TestResult::new(test_name, "GRASP-01", description)
601 .fail(&format!("Failed to build owner state event: {}", e));
602 }
603 };
604
605 if let Err(e) = client.client().send_event(&owner_state_event).await {
606 let _ = fs::remove_dir_all(&clone_path);
607 return TestResult::new(test_name, "GRASP-01", description)
608 .fail(&format!("Failed to send owner state event: {}", e));
609 }
610
611 // 9. Maintainer publishes state event for commit_b at NEWER timestamp
612 let maintainer_state_event = match client
613 .event_builder(Kind::Custom(30618), "")
614 .tag(Tag::identifier(&repo_id))
615 .tag(Tag::custom(
616 TagKind::custom("refs/heads/main"),
617 vec![commit_b.clone()],
618 ))
619 .custom_time(newer_timestamp)
620 .build(&maintainer_keys)
621 {
622 Ok(e) => e,
623 Err(e) => {
624 let _ = fs::remove_dir_all(&clone_path);
625 return TestResult::new(test_name, "GRASP-01", description)
626 .fail(&format!("Failed to build maintainer state event: {}", e));
627 }
628 };
629
630 if let Err(e) = client.client().send_event(&maintainer_state_event).await {
631 let _ = fs::remove_dir_all(&clone_path);
632 return TestResult::new(test_name, "GRASP-01", description)
633 .fail(&format!("Failed to send maintainer state event: {}", e));
634 }
635
636 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
637
638 // 10. Create and checkout main branch pointing to commit_b (the newer state)
639 let branch_output = Command::new("git")
640 .args(["branch", "main"])
641 .current_dir(&clone_path)
642 .output();
643
644 if let Ok(output) = branch_output {
645 if !output.status.success() {
646 let _ = fs::remove_dir_all(&clone_path);
647 return TestResult::new(test_name, "GRASP-01", description).fail(&format!(
648 "Failed to create main branch: {}",
649 String::from_utf8_lossy(&output.stderr)
650 ));
651 }
652 }
653
654 let checkout_output = Command::new("git")
655 .args(["checkout", "main"])
656 .current_dir(&clone_path)
657 .output();
658
659 if let Ok(output) = checkout_output {
660 if !output.status.success() {
661 let _ = fs::remove_dir_all(&clone_path);
662 return TestResult::new(test_name, "GRASP-01", description).fail(&format!(
663 "Failed to checkout main branch: {}",
664 String::from_utf8_lossy(&output.stderr)
665 ));
666 }
667 }
668
669 // 11. Attempt push - should be ACCEPTED because maintainer's newer state event
670 // announces commit_b which is now HEAD of main
671 let push_result = try_push(&clone_path);
672 let _ = fs::remove_dir_all(&clone_path);
673
674 match push_result {
675 Ok(true) => TestResult::new(test_name, "GRASP-01", description).pass(),
676 Ok(false) => TestResult::new(test_name, "GRASP-01", description).fail(&format!(
677 "Push was rejected but should have been accepted. \
678 The maintainer published a state event at timestamp {} announcing commit_b ({}). \
679 The owner published an older state event at timestamp {} announcing commit_a ({}). \
680 The relay should use the NEWER state event (maintainer's) for authorization.",
681 newer_timestamp.as_u64(),
682 commit_b,
683 older_timestamp.as_u64(),
684 commit_a
685 )),
686 Err(e) => {
687 TestResult::new(test_name, "GRASP-01", description).fail(&format!("Push error: {}", e))
688 }
689 }
690 }
691
692 /// Test push authorized by direct maintainer state event
693 /// 754 ///
694 /// GRASP-01: "respecting the recursive maintainer set" 755 /// GRASP-01: "respecting the recursive maintainer set"
695 /// This tests the first level: direct maintainers listed in the maintainers tag. 756 /// This tests that a maintainer can authorize pushes with ONLY a state event,
757 /// without publishing their own repo announcement. The maintainer is still
758 /// listed in the owner's announcement, so they're a valid maintainer.
696 /// 759 ///
697 /// Scenario: 760 /// Scenario:
698 /// 1. Owner creates repo with `["maintainers", "<maintainer-pubkey>"]` tag 761 /// 1. Owner's repo announcement lists maintainer in maintainers tag
699 /// 2. Maintainer creates their own repo announcement (same d-tag) 762 /// 2. Maintainer publishes ONLY a state event (no announcement)
700 /// 3. Maintainer publishes state event with a commit hash 763 /// 3. setup_repo_for_maintainer() clones, creates maintainer commit, verifies hash, pushes
701 /// 4. Push to that commit should be ACCEPTED 764 /// 4. The push should be ACCEPTED because maintainer's state event authorizes it
702 pub async fn test_push_authorized_by_direct_maintainer_state( 765 pub async fn test_push_authorized_by_maintainer_state_only(
703 client: &AuditClient, 766 client: &AuditClient,
704 git_data_dir: &Path, 767 git_data_dir: &Path,
705 relay_domain: &str, 768 relay_domain: &str,
706 ) -> TestResult { 769 ) -> TestResult {
707 let test_name = "test_push_authorized_by_direct_maintainer_state"; 770 let test_name = "test_push_authorized_by_maintainer_state_only";
708 771
709 // 1. Generate maintainer keypair 772 // Use setup_repo_for_maintainer which publishes ONLY the state event, no announcement
710 let maintainer_keys = Keys::generate(); 773 match setup_repo_for_maintainer(client, git_data_dir, relay_domain).await {
711 let maintainer_pubkey = maintainer_keys.public_key().to_hex(); 774 Ok(_setup) => {
712 775 // Push succeeded in setup - this means the relay accepted the push
713 // 2. Owner creates repo with maintainer listed 776 // authorized by the maintainer's state event alone
714 let repo_event = match client 777 TestResult::new(
715 .create_repo_announcement_with_maintainers(test_name, &[maintainer_pubkey.clone()])
716 .await
717 {
718 Ok(e) => e,
719 Err(e) => {
720 return TestResult::new(
721 test_name,
722 "GRASP-01",
723 "Push authorized by direct maintainer state event",
724 )
725 .fail(&format!("Failed to create repo with maintainers: {}", e))
726 }
727 };
728
729 // Send the owner's repo event
730 if let Err(e) = client.send_event(repo_event.clone()).await {
731 return TestResult::new(
732 test_name,
733 "GRASP-01",
734 "Push authorized by direct maintainer state event",
735 )
736 .fail(&format!("Failed to send owner repo event: {}", e));
737 }
738
739 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
740
741 // Extract repo details
742 let repo_id = match repo_event
743 .tags
744 .iter()
745 .find(|t| t.kind() == TagKind::d())
746 .and_then(|t| t.content())
747 {
748 Some(id) => id.to_string(),
749 None => {
750 return TestResult::new(
751 test_name,
752 "GRASP-01",
753 "Push authorized by direct maintainer state event",
754 )
755 .fail("Repository event missing d tag")
756 }
757 };
758
759 // Get relay URL for maintainer's repo announcement
760 let relay_url = match client.relay_url().await {
761 Ok(u) => u,
762 Err(e) => {
763 return TestResult::new(
764 test_name,
765 "GRASP-01",
766 "Push authorized by direct maintainer state event",
767 )
768 .fail(&format!("Failed to get relay URL: {}", e))
769 }
770 };
771 let http_url = relay_url
772 .replace("ws://", "http://")
773 .replace("wss://", "https://");
774 let maintainer_npub = match maintainer_keys.public_key().to_bech32() {
775 Ok(n) => n,
776 Err(e) => {
777 return TestResult::new(
778 test_name,
779 "GRASP-01",
780 "Push authorized by direct maintainer state event",
781 )
782 .fail(&format!("Failed to convert maintainer pubkey to npub: {}", e))
783 }
784 };
785
786 // 3. Maintainer creates their own repo announcement (same d-tag)
787 // This creates a separate repo at maintainer-npub/repo-id.git
788 let maintainer_repo_event = match client
789 .event_builder(
790 Kind::GitRepoAnnouncement,
791 format!("Maintainer's view of {} repository", test_name),
792 )
793 .tag(Tag::identifier(&repo_id))
794 .tag(Tag::custom(
795 TagKind::custom("name"),
796 vec![format!("{} Test Repository (Maintainer)", test_name)],
797 ))
798 .tag(Tag::custom(
799 TagKind::custom("clone"),
800 vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)],
801 ))
802 .tag(Tag::custom(
803 TagKind::custom("relays"),
804 vec![relay_url.clone()],
805 ))
806 .build(&maintainer_keys)
807 {
808 Ok(e) => e,
809 Err(e) => {
810 return TestResult::new(
811 test_name,
812 "GRASP-01",
813 "Push authorized by direct maintainer state event",
814 )
815 .fail(&format!("Failed to build maintainer repo event: {}", e))
816 }
817 };
818
819 if let Err(e) = client.client().send_event(&maintainer_repo_event).await {
820 return TestResult::new(
821 test_name,
822 "GRASP-01",
823 "Push authorized by direct maintainer state event",
824 )
825 .fail(&format!("Failed to send maintainer repo event: {}", e));
826 }
827
828 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
829
830 // Verify maintainer's repo was created
831 let maintainer_repo_path = git_data_dir
832 .join(&maintainer_npub)
833 .join(format!("{}.git", repo_id));
834 if !maintainer_repo_path.exists() {
835 return TestResult::new(
836 test_name,
837 "GRASP-01",
838 "Push authorized by direct maintainer state event",
839 )
840 .fail(&format!(
841 "Maintainer repo not created at: {}",
842 maintainer_repo_path.display()
843 ));
844 }
845
846 // 4. Clone maintainer's repo
847 let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) {
848 Ok(p) => p,
849 Err(e) => {
850 return TestResult::new(
851 test_name, 778 test_name,
852 "GRASP-01", 779 "GRASP-01",
853 "Push authorized by direct maintainer state event", 780 "Push authorized by maintainer state event only (no announcement)",
854 ) 781 )
855 .fail(&format!("Failed to clone maintainer repo: {}", e)) 782 .pass()
856 } 783 }
857 }; 784 Err(e) => {
858 785 // Check if this was specifically a push rejection
859 // 5. Create deterministic commit 786 if e.contains("Failed to push") {
860 let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") { 787 TestResult::new(
861 Ok(h) => h, 788 test_name,
862 Err(e) => { 789 "GRASP-01",
863 let _ = fs::remove_dir_all(&clone_path); 790 "Push authorized by maintainer state event only (no announcement)",
864 return TestResult::new( 791 )
865 test_name, 792 .fail(&format!(
866 "GRASP-01", 793 "Push was rejected but should have been accepted. \
867 "Push authorized by direct maintainer state event", 794 The maintainer published a state event with a commit hash, \
868 ) 795 and even without a separate announcement, the relay should \
869 .fail(&format!("Failed to create commit: {}", e)); 796 authorize pushes matching this state event since the maintainer \
870 } 797 is listed in the owner's announcement. \
871 }; 798 Error: {}",
872 799 e
873 // 6. Maintainer publishes state event with commit hash 800 ))
874 let state_event = match client 801 } else {
875 .event_builder(Kind::Custom(30618), "") 802 // Some other error during setup
876 .tag(Tag::identifier(&repo_id)) 803 TestResult::new(
877 .tag(Tag::custom( 804 test_name,
878 TagKind::custom("refs/heads/main"), 805 "GRASP-01",
879 vec![commit_hash.clone()], 806 "Push authorized by maintainer state event only (no announcement)",
880 )) 807 )
881 .build(&maintainer_keys) 808 .fail(&format!("Setup failed: {}", e))
882 { 809 }
883 Ok(e) => e,
884 Err(e) => {
885 let _ = fs::remove_dir_all(&clone_path);
886 return TestResult::new(
887 test_name,
888 "GRASP-01",
889 "Push authorized by direct maintainer state event",
890 )
891 .fail(&format!("Failed to build state event: {}", e));
892 }
893 };
894
895 if let Err(e) = client.client().send_event(&state_event).await {
896 let _ = fs::remove_dir_all(&clone_path);
897 return TestResult::new(
898 test_name,
899 "GRASP-01",
900 "Push authorized by direct maintainer state event",
901 )
902 .fail(&format!("Failed to send state event: {}", e));
903 }
904
905 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
906
907 // 7. Create and checkout main branch
908 let branch_output = Command::new("git")
909 .args(["branch", "main"])
910 .current_dir(&clone_path)
911 .output();
912
913 if let Ok(output) = branch_output {
914 if !output.status.success() {
915 let _ = fs::remove_dir_all(&clone_path);
916 return TestResult::new(
917 test_name,
918 "GRASP-01",
919 "Push authorized by direct maintainer state event",
920 )
921 .fail(&format!(
922 "Failed to create main branch: {}",
923 String::from_utf8_lossy(&output.stderr)
924 ));
925 }
926 }
927
928 let checkout_output = Command::new("git")
929 .args(["checkout", "main"])
930 .current_dir(&clone_path)
931 .output();
932
933 if let Ok(output) = checkout_output {
934 if !output.status.success() {
935 let _ = fs::remove_dir_all(&clone_path);
936 return TestResult::new(
937 test_name,
938 "GRASP-01",
939 "Push authorized by direct maintainer state event",
940 )
941 .fail(&format!(
942 "Failed to checkout main branch: {}",
943 String::from_utf8_lossy(&output.stderr)
944 ));
945 } 810 }
946 } 811 }
947
948 // 8. Attempt push - should be ACCEPTED because maintainer's state event authorizes it
949 let push_result = try_push(&clone_path);
950 let _ = fs::remove_dir_all(&clone_path);
951
952 match push_result {
953 Ok(true) => TestResult::new(
954 test_name,
955 "GRASP-01",
956 "Push authorized by direct maintainer state event",
957 )
958 .pass(),
959 Ok(false) => TestResult::new(
960 test_name,
961 "GRASP-01",
962 "Push authorized by direct maintainer state event",
963 )
964 .fail(&format!(
965 "Push was rejected but should have been accepted. \
966 The maintainer (pubkey: {}) is listed in the owner's maintainers tag \
967 and published a state event announcing commit {}. \
968 The relay should authorize pushes matching this state event.",
969 maintainer_pubkey, commit_hash
970 )),
971 Err(e) => TestResult::new(
972 test_name,
973 "GRASP-01",
974 "Push authorized by direct maintainer state event",
975 )
976 .fail(&format!("Push error: {}", e)),
977 }
978 } 812 }
979 813
980 /// Test push authorized by recursive maintainer state event 814 /// Test push authorized by recursive maintainer state event
@@ -983,11 +817,12 @@ impl PushAuthorizationTests {
983 /// This tests recursive maintainer chains: Owner -> MaintainerA -> MaintainerB 817 /// This tests recursive maintainer chains: Owner -> MaintainerA -> MaintainerB
984 /// 818 ///
985 /// Scenario: 819 /// Scenario:
986 /// 1. Owner creates repo with `["maintainers", "<maintainerA-pubkey>"]` tag 820 /// 1. RecursiveMaintainerRepoAndState fixture creates:
987 /// 2. MaintainerA creates their own repo announcement (same d-tag) with MaintainerB 821 /// - Repo announcement signed by recursive_maintainer keys
988 /// 3. MaintainerB creates their own repo announcement (same d-tag, no further maintainers) 822 /// - Lists main pubkey and maintainer pubkey in maintainers tag
989 /// 4. MaintainerB publishes state event with a commit hash 823 /// - State event with RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH (2s in past)
990 /// 5. Push to that commit should be ACCEPTED (recursive maintainer chain) 824 /// 2. setup_repo_for_recursive_maintainer() clones, creates recursive maintainer commit, verifies hash, pushes
825 /// 3. The push should be ACCEPTED because recursive maintainer's state event authorizes it
991 pub async fn test_push_authorized_by_recursive_maintainer_state( 826 pub async fn test_push_authorized_by_recursive_maintainer_state(
992 client: &AuditClient, 827 client: &AuditClient,
993 git_data_dir: &Path, 828 git_data_dir: &Path,
@@ -995,338 +830,52 @@ impl PushAuthorizationTests {
995 ) -> TestResult { 830 ) -> TestResult {
996 let test_name = "test_push_authorized_by_recursive_maintainer_state"; 831 let test_name = "test_push_authorized_by_recursive_maintainer_state";
997 832
998 // 1. Generate MaintainerA and MaintainerB keypairs 833 // Use setup_repo_for_recursive_maintainer which leverages RecursiveMaintainerRepoAndState fixture
999 let maintainer_a_keys = Keys::generate(); 834 // This does all the heavy lifting:
1000 let maintainer_a_pubkey = maintainer_a_keys.public_key().to_hex(); 835 // 1. Creates repo announcement signed by recursive maintainer keys
1001 836 // 2. Creates state event pointing to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
1002 let maintainer_b_keys = Keys::generate(); 837 // 3. Clones the repo
1003 let maintainer_b_pubkey = maintainer_b_keys.public_key().to_hex(); 838 // 4. Creates the recursive maintainer deterministic commit locally
1004 839 // 5. Verifies commit hash matches expected
1005 // 2. Owner creates repo with MaintainerA listed 840 // 6. Creates main branch, checks it out, and pushes
1006 let repo_event = match client 841 match setup_repo_for_recursive_maintainer(client, git_data_dir, relay_domain).await {
1007 .create_repo_announcement_with_maintainers(test_name, &[maintainer_a_pubkey.clone()]) 842 Ok(_setup) => {
1008 .await 843 // Push succeeded in setup - this means the relay accepted the push
1009 { 844 // authorized by the recursive maintainer's state event
1010 Ok(e) => e, 845 TestResult::new(
1011 Err(e) => {
1012 return TestResult::new(
1013 test_name,
1014 "GRASP-01",
1015 "Push authorized by recursive maintainer state event",
1016 )
1017 .fail(&format!("Failed to create repo with maintainers: {}", e))
1018 }
1019 };
1020
1021 // Send the owner's repo event
1022 if let Err(e) = client.send_event(repo_event.clone()).await {
1023 return TestResult::new(
1024 test_name,
1025 "GRASP-01",
1026 "Push authorized by recursive maintainer state event",
1027 )
1028 .fail(&format!("Failed to send owner repo event: {}", e));
1029 }
1030
1031 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1032
1033 // Extract repo details
1034 let repo_id = match repo_event
1035 .tags
1036 .iter()
1037 .find(|t| t.kind() == TagKind::d())
1038 .and_then(|t| t.content())
1039 {
1040 Some(id) => id.to_string(),
1041 None => {
1042 return TestResult::new(
1043 test_name,
1044 "GRASP-01",
1045 "Push authorized by recursive maintainer state event",
1046 )
1047 .fail("Repository event missing d tag")
1048 }
1049 };
1050
1051 // Get relay URL for maintainers' repo announcements
1052 let relay_url = match client.relay_url().await {
1053 Ok(u) => u,
1054 Err(e) => {
1055 return TestResult::new(
1056 test_name,
1057 "GRASP-01",
1058 "Push authorized by recursive maintainer state event",
1059 )
1060 .fail(&format!("Failed to get relay URL: {}", e))
1061 }
1062 };
1063 let http_url = relay_url
1064 .replace("ws://", "http://")
1065 .replace("wss://", "https://");
1066
1067 let maintainer_a_npub = match maintainer_a_keys.public_key().to_bech32() {
1068 Ok(n) => n,
1069 Err(e) => {
1070 return TestResult::new(
1071 test_name, 846 test_name,
1072 "GRASP-01", 847 "GRASP-01",
1073 "Push authorized by recursive maintainer state event", 848 "Push authorized by recursive maintainer state event",
1074 ) 849 )
1075 .fail(&format!("Failed to convert maintainer A pubkey to npub: {}", e)) 850 .pass()
1076 } 851 }
1077 }; 852 Err(e) => {
1078 853 // Check if this was specifically a push rejection
1079 let maintainer_b_npub = match maintainer_b_keys.public_key().to_bech32() { 854 if e.contains("Failed to push") {
1080 Ok(n) => n, 855 TestResult::new(
1081 Err(e) => { 856 test_name,
1082 return TestResult::new( 857 "GRASP-01",
1083 test_name, 858 "Push authorized by recursive maintainer state event",
1084 "GRASP-01", 859 )
1085 "Push authorized by recursive maintainer state event", 860 .fail(&format!(
1086 ) 861 "Push was rejected but should have been accepted. \
1087 .fail(&format!("Failed to convert maintainer B pubkey to npub: {}", e)) 862 The recursive maintainer published a state event with a commit hash, \
1088 } 863 and the relay should authorize pushes matching this state event \
1089 }; 864 through recursive maintainer traversal. \
1090 865 Error: {}",
1091 // 3. MaintainerA creates their own repo announcement (same d-tag) with MaintainerB listed 866 e
1092 let maintainer_a_repo_event = match client 867 ))
1093 .event_builder( 868 } else {
1094 Kind::GitRepoAnnouncement, 869 // Some other error during setup
1095 format!("MaintainerA's view of {} repository", test_name), 870 TestResult::new(
1096 ) 871 test_name,
1097 .tag(Tag::identifier(&repo_id)) 872 "GRASP-01",
1098 .tag(Tag::custom( 873 "Push authorized by recursive maintainer state event",
1099 TagKind::custom("name"), 874 )
1100 vec![format!("{} Test Repository (MaintainerA)", test_name)], 875 .fail(&format!("Setup failed: {}", e))
1101 )) 876 }
1102 .tag(Tag::custom(
1103 TagKind::custom("clone"),
1104 vec![format!("{}/{}/{}.git", http_url, maintainer_a_npub, repo_id)],
1105 ))
1106 .tag(Tag::custom(
1107 TagKind::custom("relays"),
1108 vec![relay_url.clone()],
1109 ))
1110 .tag(Tag::custom(
1111 TagKind::custom("maintainers"),
1112 vec![maintainer_b_pubkey.clone()],
1113 ))
1114 .build(&maintainer_a_keys)
1115 {
1116 Ok(e) => e,
1117 Err(e) => {
1118 return TestResult::new(
1119 test_name,
1120 "GRASP-01",
1121 "Push authorized by recursive maintainer state event",
1122 )
1123 .fail(&format!("Failed to build maintainer A repo event: {}", e))
1124 }
1125 };
1126
1127 if let Err(e) = client.client().send_event(&maintainer_a_repo_event).await {
1128 return TestResult::new(
1129 test_name,
1130 "GRASP-01",
1131 "Push authorized by recursive maintainer state event",
1132 )
1133 .fail(&format!("Failed to send maintainer A repo event: {}", e));
1134 }
1135
1136 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1137
1138 // 4. MaintainerB creates their own repo announcement (same d-tag, no further maintainers)
1139 let maintainer_b_repo_event = match client
1140 .event_builder(
1141 Kind::GitRepoAnnouncement,
1142 format!("MaintainerB's view of {} repository", test_name),
1143 )
1144 .tag(Tag::identifier(&repo_id))
1145 .tag(Tag::custom(
1146 TagKind::custom("name"),
1147 vec![format!("{} Test Repository (MaintainerB)", test_name)],
1148 ))
1149 .tag(Tag::custom(
1150 TagKind::custom("clone"),
1151 vec![format!("{}/{}/{}.git", http_url, maintainer_b_npub, repo_id)],
1152 ))
1153 .tag(Tag::custom(
1154 TagKind::custom("relays"),
1155 vec![relay_url.clone()],
1156 ))
1157 .build(&maintainer_b_keys)
1158 {
1159 Ok(e) => e,
1160 Err(e) => {
1161 return TestResult::new(
1162 test_name,
1163 "GRASP-01",
1164 "Push authorized by recursive maintainer state event",
1165 )
1166 .fail(&format!("Failed to build maintainer B repo event: {}", e))
1167 }
1168 };
1169
1170 if let Err(e) = client.client().send_event(&maintainer_b_repo_event).await {
1171 return TestResult::new(
1172 test_name,
1173 "GRASP-01",
1174 "Push authorized by recursive maintainer state event",
1175 )
1176 .fail(&format!("Failed to send maintainer B repo event: {}", e));
1177 }
1178
1179 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1180
1181 // Verify maintainer B's repo was created
1182 let maintainer_b_repo_path = git_data_dir
1183 .join(&maintainer_b_npub)
1184 .join(format!("{}.git", repo_id));
1185 if !maintainer_b_repo_path.exists() {
1186 return TestResult::new(
1187 test_name,
1188 "GRASP-01",
1189 "Push authorized by recursive maintainer state event",
1190 )
1191 .fail(&format!(
1192 "Maintainer B repo not created at: {}",
1193 maintainer_b_repo_path.display()
1194 ));
1195 }
1196
1197 // 5. Clone maintainer B's repo
1198 let clone_path = match clone_repo(relay_domain, &maintainer_b_npub, &repo_id) {
1199 Ok(p) => p,
1200 Err(e) => {
1201 return TestResult::new(
1202 test_name,
1203 "GRASP-01",
1204 "Push authorized by recursive maintainer state event",
1205 )
1206 .fail(&format!("Failed to clone maintainer B repo: {}", e))
1207 }
1208 };
1209
1210 // 6. Create deterministic commit
1211 let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") {
1212 Ok(h) => h,
1213 Err(e) => {
1214 let _ = fs::remove_dir_all(&clone_path);
1215 return TestResult::new(
1216 test_name,
1217 "GRASP-01",
1218 "Push authorized by recursive maintainer state event",
1219 )
1220 .fail(&format!("Failed to create commit: {}", e));
1221 }
1222 };
1223
1224 // 7. MaintainerB publishes state event with commit hash
1225 let state_event = match client
1226 .event_builder(Kind::Custom(30618), "")
1227 .tag(Tag::identifier(&repo_id))
1228 .tag(Tag::custom(
1229 TagKind::custom("refs/heads/main"),
1230 vec![commit_hash.clone()],
1231 ))
1232 .build(&maintainer_b_keys)
1233 {
1234 Ok(e) => e,
1235 Err(e) => {
1236 let _ = fs::remove_dir_all(&clone_path);
1237 return TestResult::new(
1238 test_name,
1239 "GRASP-01",
1240 "Push authorized by recursive maintainer state event",
1241 )
1242 .fail(&format!("Failed to build state event: {}", e));
1243 }
1244 };
1245
1246 if let Err(e) = client.client().send_event(&state_event).await {
1247 let _ = fs::remove_dir_all(&clone_path);
1248 return TestResult::new(
1249 test_name,
1250 "GRASP-01",
1251 "Push authorized by recursive maintainer state event",
1252 )
1253 .fail(&format!("Failed to send state event: {}", e));
1254 }
1255
1256 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1257
1258 // 8. Create and checkout main branch
1259 let branch_output = Command::new("git")
1260 .args(["branch", "main"])
1261 .current_dir(&clone_path)
1262 .output();
1263
1264 if let Ok(output) = branch_output {
1265 if !output.status.success() {
1266 let _ = fs::remove_dir_all(&clone_path);
1267 return TestResult::new(
1268 test_name,
1269 "GRASP-01",
1270 "Push authorized by recursive maintainer state event",
1271 )
1272 .fail(&format!(
1273 "Failed to create main branch: {}",
1274 String::from_utf8_lossy(&output.stderr)
1275 ));
1276 }
1277 }
1278
1279 let checkout_output = Command::new("git")
1280 .args(["checkout", "main"])
1281 .current_dir(&clone_path)
1282 .output();
1283
1284 if let Ok(output) = checkout_output {
1285 if !output.status.success() {
1286 let _ = fs::remove_dir_all(&clone_path);
1287 return TestResult::new(
1288 test_name,
1289 "GRASP-01",
1290 "Push authorized by recursive maintainer state event",
1291 )
1292 .fail(&format!(
1293 "Failed to checkout main branch: {}",
1294 String::from_utf8_lossy(&output.stderr)
1295 ));
1296 } 877 }
1297 } 878 }
1298
1299 // 9. Attempt push - should be ACCEPTED because recursive maintainer chain authorizes it
1300 // Owner -> MaintainerA -> MaintainerB, and MaintainerB has published the state event
1301 let push_result = try_push(&clone_path);
1302 let _ = fs::remove_dir_all(&clone_path);
1303
1304 match push_result {
1305 Ok(true) => TestResult::new(
1306 test_name,
1307 "GRASP-01",
1308 "Push authorized by recursive maintainer state event",
1309 )
1310 .pass(),
1311 Ok(false) => TestResult::new(
1312 test_name,
1313 "GRASP-01",
1314 "Push authorized by recursive maintainer state event",
1315 )
1316 .fail(&format!(
1317 "Push was rejected but should have been accepted. \
1318 The recursive maintainer chain is: Owner -> MaintainerA (pubkey: {}) -> MaintainerB (pubkey: {}). \
1319 MaintainerB published a state event announcing commit {}. \
1320 The relay should authorize pushes matching this state event through recursive maintainer traversal.",
1321 maintainer_a_pubkey, maintainer_b_pubkey, commit_hash
1322 )),
1323 Err(e) => TestResult::new(
1324 test_name,
1325 "GRASP-01",
1326 "Push authorized by recursive maintainer state event",
1327 )
1328 .fail(&format!("Push error: {}", e)),
1329 }
1330 } 879 }
1331 880
1332 /// Test that non-maintainer state event is ignored 881 /// Test that non-maintainer state event is ignored
@@ -1413,279 +962,6 @@ impl PushAuthorizationTests {
1413 Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e), 962 Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e),
1414 } 963 }
1415 } 964 }
1416
1417 /// Test that owner's newer state event beats maintainer's older state event
1418 ///
1419 /// GRASP-01 requires that the relay use the LATEST state event (by created_at
1420 /// timestamp) when determining push authorization. This test is the MIRROR of
1421 /// test_latest_state_event_used - confirming that timestamp is the deciding factor,
1422 /// not who authored the state event.
1423 ///
1424 /// Scenario:
1425 /// 1. Owner creates repo with maintainer
1426 /// 2. Maintainer publishes state event for commit_a at t=100 (older)
1427 /// 3. Owner publishes state event for commit_b at t=200 (newer)
1428 /// 4. Push commit_b should be ACCEPTED (owner's newer state wins)
1429 /// 5. Push commit_a should be REJECTED (maintainer's older state superseded)
1430 ///
1431 /// Key difference from test_latest_state_event_used:
1432 /// - Task 8: Owner=older, Maintainer=newer → Maintainer wins
1433 /// - Task 9: Maintainer=older, Owner=newer → Owner wins
1434 /// - **This confirms symmetry**: timestamp is the deciding factor
1435 pub async fn test_owner_newer_state_beats_maintainer(
1436 client: &AuditClient,
1437 git_data_dir: &Path,
1438 relay_domain: &str,
1439 ) -> TestResult {
1440 let test_name = "test_owner_newer_state_beats_maintainer";
1441 let description = "Owner's newer state event beats maintainer's older state";
1442
1443 // 1. Generate maintainer keypair
1444 let maintainer_keys = Keys::generate();
1445 let maintainer_pubkey = maintainer_keys.public_key().to_hex();
1446
1447 // 2. Owner creates repo with maintainer
1448 let repo_event = match client
1449 .create_repo_announcement_with_maintainers(test_name, &[maintainer_pubkey.clone()])
1450 .await
1451 {
1452 Ok(e) => e,
1453 Err(e) => {
1454 return TestResult::new(test_name, "GRASP-01", description)
1455 .fail(&format!("Failed to create repo with maintainers: {}", e))
1456 }
1457 };
1458
1459 // Send the owner's repo event
1460 if let Err(e) = client.send_event(repo_event.clone()).await {
1461 return TestResult::new(test_name, "GRASP-01", description)
1462 .fail(&format!("Failed to send owner repo event: {}", e));
1463 }
1464
1465 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1466
1467 // Extract repo details
1468 let repo_id = match repo_event
1469 .tags
1470 .iter()
1471 .find(|t| t.kind() == TagKind::d())
1472 .and_then(|t| t.content())
1473 {
1474 Some(id) => id.to_string(),
1475 None => {
1476 return TestResult::new(test_name, "GRASP-01", description)
1477 .fail("Repository event missing d tag")
1478 }
1479 };
1480
1481 // Get relay URL for maintainer's repo announcement
1482 let relay_url = match client.relay_url().await {
1483 Ok(u) => u,
1484 Err(e) => {
1485 return TestResult::new(test_name, "GRASP-01", description)
1486 .fail(&format!("Failed to get relay URL: {}", e))
1487 }
1488 };
1489 let http_url = relay_url
1490 .replace("ws://", "http://")
1491 .replace("wss://", "https://");
1492 let maintainer_npub = match maintainer_keys.public_key().to_bech32() {
1493 Ok(n) => n,
1494 Err(e) => {
1495 return TestResult::new(test_name, "GRASP-01", description)
1496 .fail(&format!("Failed to convert maintainer pubkey to npub: {}", e))
1497 }
1498 };
1499
1500 // 3. Maintainer creates their own repo announcement (same d-tag)
1501 let maintainer_repo_event = match client
1502 .event_builder(
1503 Kind::GitRepoAnnouncement,
1504 format!("Maintainer's view of {} repository", test_name),
1505 )
1506 .tag(Tag::identifier(&repo_id))
1507 .tag(Tag::custom(
1508 TagKind::custom("name"),
1509 vec![format!("{} Test Repository (Maintainer)", test_name)],
1510 ))
1511 .tag(Tag::custom(
1512 TagKind::custom("clone"),
1513 vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)],
1514 ))
1515 .tag(Tag::custom(
1516 TagKind::custom("relays"),
1517 vec![relay_url.clone()],
1518 ))
1519 .build(&maintainer_keys)
1520 {
1521 Ok(e) => e,
1522 Err(e) => {
1523 return TestResult::new(test_name, "GRASP-01", description)
1524 .fail(&format!("Failed to build maintainer repo event: {}", e))
1525 }
1526 };
1527
1528 if let Err(e) = client.client().send_event(&maintainer_repo_event).await {
1529 return TestResult::new(test_name, "GRASP-01", description)
1530 .fail(&format!("Failed to send maintainer repo event: {}", e));
1531 }
1532
1533 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1534
1535 // Verify maintainer's repo was created
1536 let maintainer_repo_path = git_data_dir
1537 .join(&maintainer_npub)
1538 .join(format!("{}.git", repo_id));
1539 if !maintainer_repo_path.exists() {
1540 return TestResult::new(test_name, "GRASP-01", description).fail(&format!(
1541 "Maintainer repo not created at: {}",
1542 maintainer_repo_path.display()
1543 ));
1544 }
1545
1546 // 4. Clone maintainer's repo
1547 let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) {
1548 Ok(p) => p,
1549 Err(e) => {
1550 return TestResult::new(test_name, "GRASP-01", description)
1551 .fail(&format!("Failed to clone maintainer repo: {}", e))
1552 }
1553 };
1554
1555 // 5. Create first commit (commit_a) - MAINTAINER will announce this with OLDER timestamp
1556 let commit_a = match create_commit(&clone_path, "Commit A - older state (maintainer)") {
1557 Ok(h) => h,
1558 Err(e) => {
1559 let _ = fs::remove_dir_all(&clone_path);
1560 return TestResult::new(test_name, "GRASP-01", description)
1561 .fail(&format!("Failed to create commit_a: {}", e));
1562 }
1563 };
1564
1565 // 6. Create second commit (commit_b) - OWNER will announce this with NEWER timestamp
1566 let commit_b = match create_commit(&clone_path, "Commit B - newer state (owner)") {
1567 Ok(h) => h,
1568 Err(e) => {
1569 let _ = fs::remove_dir_all(&clone_path);
1570 return TestResult::new(test_name, "GRASP-01", description)
1571 .fail(&format!("Failed to create commit_b: {}", e));
1572 }
1573 };
1574
1575 // 7. Calculate timestamps: older_timestamp (100 seconds ago) and newer_timestamp (now)
1576 let base_time = Timestamp::now().as_u64();
1577 let older_timestamp = Timestamp::from(base_time - 100); // 100 seconds ago - for MAINTAINER
1578 let newer_timestamp = Timestamp::from(base_time); // now - for OWNER
1579
1580 // 8. MAINTAINER publishes state event for commit_a at OLDER timestamp
1581 // This is the KEY DIFFERENCE from test_latest_state_event_used:
1582 // - In Task 8: Owner was older, Maintainer was newer
1583 // - In Task 9 (this test): Maintainer is older, Owner is newer
1584 let maintainer_state_event = match client
1585 .event_builder(Kind::Custom(30618), "")
1586 .tag(Tag::identifier(&repo_id))
1587 .tag(Tag::custom(
1588 TagKind::custom("refs/heads/main"),
1589 vec![commit_a.clone()],
1590 ))
1591 .custom_time(older_timestamp)
1592 .build(&maintainer_keys)
1593 {
1594 Ok(e) => e,
1595 Err(e) => {
1596 let _ = fs::remove_dir_all(&clone_path);
1597 return TestResult::new(test_name, "GRASP-01", description)
1598 .fail(&format!("Failed to build maintainer state event: {}", e));
1599 }
1600 };
1601
1602 if let Err(e) = client.client().send_event(&maintainer_state_event).await {
1603 let _ = fs::remove_dir_all(&clone_path);
1604 return TestResult::new(test_name, "GRASP-01", description)
1605 .fail(&format!("Failed to send maintainer state event: {}", e));
1606 }
1607
1608 // 9. OWNER publishes state event for commit_b at NEWER timestamp
1609 let owner_state_event = match client
1610 .event_builder(Kind::Custom(30618), "")
1611 .tag(Tag::identifier(&repo_id))
1612 .tag(Tag::custom(
1613 TagKind::custom("refs/heads/main"),
1614 vec![commit_b.clone()],
1615 ))
1616 .custom_time(newer_timestamp)
1617 .build(client.keys())
1618 {
1619 Ok(e) => e,
1620 Err(e) => {
1621 let _ = fs::remove_dir_all(&clone_path);
1622 return TestResult::new(test_name, "GRASP-01", description)
1623 .fail(&format!("Failed to build owner state event: {}", e));
1624 }
1625 };
1626
1627 if let Err(e) = client.client().send_event(&owner_state_event).await {
1628 let _ = fs::remove_dir_all(&clone_path);
1629 return TestResult::new(test_name, "GRASP-01", description)
1630 .fail(&format!("Failed to send owner state event: {}", e));
1631 }
1632
1633 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1634
1635 // 10. Create and checkout main branch pointing to commit_b (the newer state)
1636 let branch_output = Command::new("git")
1637 .args(["branch", "main"])
1638 .current_dir(&clone_path)
1639 .output();
1640
1641 if let Ok(output) = branch_output {
1642 if !output.status.success() {
1643 let _ = fs::remove_dir_all(&clone_path);
1644 return TestResult::new(test_name, "GRASP-01", description).fail(&format!(
1645 "Failed to create main branch: {}",
1646 String::from_utf8_lossy(&output.stderr)
1647 ));
1648 }
1649 }
1650
1651 let checkout_output = Command::new("git")
1652 .args(["checkout", "main"])
1653 .current_dir(&clone_path)
1654 .output();
1655
1656 if let Ok(output) = checkout_output {
1657 if !output.status.success() {
1658 let _ = fs::remove_dir_all(&clone_path);
1659 return TestResult::new(test_name, "GRASP-01", description).fail(&format!(
1660 "Failed to checkout main branch: {}",
1661 String::from_utf8_lossy(&output.stderr)
1662 ));
1663 }
1664 }
1665
1666 // 11. Attempt push - should be ACCEPTED because owner's newer state event
1667 // announces commit_b which is now HEAD of main
1668 let push_result = try_push(&clone_path);
1669 let _ = fs::remove_dir_all(&clone_path);
1670
1671 match push_result {
1672 Ok(true) => TestResult::new(test_name, "GRASP-01", description).pass(),
1673 Ok(false) => TestResult::new(test_name, "GRASP-01", description).fail(&format!(
1674 "Push was rejected but should have been accepted. \
1675 The OWNER published a state event at timestamp {} announcing commit_b ({}). \
1676 The MAINTAINER published an older state event at timestamp {} announcing commit_a ({}). \
1677 The relay should use the NEWER state event (owner's) for authorization. \
1678 This confirms symmetry with test_latest_state_event_used: timestamp is the deciding factor.",
1679 newer_timestamp.as_u64(),
1680 commit_b,
1681 older_timestamp.as_u64(),
1682 commit_a
1683 )),
1684 Err(e) => {
1685 TestResult::new(test_name, "GRASP-01", description).fail(&format!("Push error: {}", e))
1686 }
1687 }
1688 }
1689} 965}
1690 966
1691#[cfg(test)] 967#[cfg(test)]