upleb.uk

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

summaryrefslogtreecommitdiff
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
parent158d3f0722e731f2b534951069c322c5cbb5a721 (diff)
test: use fixtures in push tests
-rw-r--r--grasp-audit/src/client.rs147
-rw-r--r--grasp-audit/src/fixtures.rs378
-rw-r--r--grasp-audit/src/lib.rs5
-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
-rw-r--r--tests/push_authorization.rs3
7 files changed, 870 insertions, 1227 deletions
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs
index b2a4e38..8b96f4f 100644
--- a/grasp-audit/src/client.rs
+++ b/grasp-audit/src/client.rs
@@ -10,6 +10,10 @@ pub struct AuditClient {
10 client: Client, 10 client: Client,
11 pub config: AuditConfig, 11 pub config: AuditConfig,
12 keys: Keys, 12 keys: Keys,
13 /// Maintainer keys for testing push authorization scenarios
14 maintainer_keys: Keys,
15 /// Recursive maintainer keys for testing recursive authorization scenarios
16 recursive_maintainer_keys: Keys,
13} 17}
14 18
15impl AuditClient { 19impl AuditClient {
@@ -17,17 +21,23 @@ impl AuditClient {
17 #[cfg(test)] 21 #[cfg(test)]
18 pub fn new_test(config: AuditConfig) -> Self { 22 pub fn new_test(config: AuditConfig) -> Self {
19 let keys = Keys::generate(); 23 let keys = Keys::generate();
24 let maintainer_keys = Keys::generate();
25 let recursive_maintainer_keys = Keys::generate();
20 let client = Client::new(keys.clone()); 26 let client = Client::new(keys.clone());
21 Self { 27 Self {
22 client, 28 client,
23 config, 29 config,
24 keys, 30 keys,
31 maintainer_keys,
32 recursive_maintainer_keys,
25 } 33 }
26 } 34 }
27 35
28 /// Create a new audit client 36 /// Create a new audit client
29 pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> { 37 pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> {
30 let keys = Keys::generate(); 38 let keys = Keys::generate();
39 let maintainer_keys = Keys::generate();
40 let recursive_maintainer_keys = Keys::generate();
31 let client = Client::new(keys.clone()); 41 let client = Client::new(keys.clone());
32 42
33 // Add relay and connect 43 // Add relay and connect
@@ -76,6 +86,8 @@ impl AuditClient {
76 client, 86 client,
77 config, 87 config,
78 keys, 88 keys,
89 maintainer_keys,
90 recursive_maintainer_keys,
79 }) 91 })
80 } 92 }
81 93
@@ -222,17 +234,45 @@ impl AuditClient {
222 &self.keys 234 &self.keys
223 } 235 }
224 236
225 /// Create a NIP-34 repository announcement event 237 /// Get the maintainer keys (for push authorization testing)
238 pub fn maintainer_keys(&self) -> &Keys {
239 &self.maintainer_keys
240 }
241
242 /// Get the maintainer public key as a hex string
243 pub fn maintainer_pubkey_hex(&self) -> String {
244 self.maintainer_keys.public_key().to_hex()
245 }
246
247 /// Get the recursive maintainer keys (for recursive authorization testing)
248 pub fn recursive_maintainer_keys(&self) -> &Keys {
249 &self.recursive_maintainer_keys
250 }
251
252 /// Get the recursive maintainer public key as a hex string
253 pub fn recursive_maintainer_pubkey_hex(&self) -> String {
254 self.recursive_maintainer_keys.public_key().to_hex()
255 }
256
257 /// Create a NIP-34 repository announcement event with full customization
226 /// 258 ///
227 /// This helper creates a properly formatted NIP-34 announcement that will be 259 /// This is the core method for creating repository announcements. It allows
228 /// accepted by GRASP relays (which require events to list the relay in clone/relays tags). 260 /// specifying the signing keys and maintainers, making it suitable for all
261 /// repo creation scenarios including maintainer and recursive maintainer testing.
229 /// 262 ///
230 /// # Arguments 263 /// # Arguments
231 /// * `test_name` - Name of the test (used to create unique repo identifier) 264 /// * `test_name` - Name of the test (used to create unique repo identifier)
265 /// * `signing_keys` - The keys to sign the event with (also used for clone URL)
266 /// * `maintainer_pubkeys` - Hex pubkeys of maintainers who can push to the repository
232 /// 267 ///
233 /// # Returns 268 /// # Returns
234 /// A built and signed Event ready to be sent to the relay 269 /// A tuple of (Event, repo_id) - the built event and the repository identifier
235 pub async fn create_repo_announcement(&self, test_name: &str) -> Result<Event> { 270 pub async fn create_repo_announcement_custom(
271 &self,
272 test_name: &str,
273 signing_keys: &Keys,
274 maintainer_pubkeys: &[String],
275 ) -> Result<(Event, String)> {
236 // Get relay URL from client 276 // Get relay URL from client
237 let relay_url = self 277 let relay_url = self
238 .client 278 .client
@@ -251,8 +291,8 @@ impl AuditClient {
251 // Create unique repository identifier using UUID for consistency 291 // Create unique repository identifier using UUID for consistency
252 let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]); 292 let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]);
253 293
254 // Get npub for clone URL 294 // Get npub for clone URL from signing keys
255 let npub = self 295 let npub = signing_keys
256 .public_key() 296 .public_key()
257 .to_bech32() 297 .to_bech32()
258 .map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?; 298 .map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?;
@@ -280,9 +320,35 @@ impl AuditClient {
280 TagKind::custom("relays"), 320 TagKind::custom("relays"),
281 vec![relay_url.clone()], 321 vec![relay_url.clone()],
282 )) 322 ))
283 .build(self.keys()) 323 .tag(Tag::custom(
324 TagKind::custom("maintainers"),
325 maintainer_pubkeys.to_vec(),
326 ))
327 .build(signing_keys)
284 .map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?; 328 .map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?;
285 329
330 Ok((event, repo_id))
331 }
332
333 /// Create a NIP-34 repository announcement event with the client's maintainer
334 ///
335 /// This helper creates a properly formatted NIP-34 announcement that will be
336 /// accepted by GRASP relays (which require events to list the relay in clone/relays tags).
337 /// The client's maintainer key is automatically added to the maintainers tag.
338 ///
339 /// # Arguments
340 /// * `test_name` - Name of the test (used to create unique repo identifier)
341 ///
342 /// # Returns
343 /// A built and signed Event ready to be sent to the relay
344 pub async fn create_repo_announcement(&self, test_name: &str) -> Result<Event> {
345 let (event, _repo_id) = self
346 .create_repo_announcement_custom(
347 test_name,
348 self.keys(),
349 &[self.maintainer_pubkey_hex()],
350 )
351 .await?;
286 Ok(event) 352 Ok(event)
287 } 353 }
288 354
@@ -303,60 +369,9 @@ impl AuditClient {
303 test_name: &str, 369 test_name: &str,
304 maintainer_pubkeys: &[String], 370 maintainer_pubkeys: &[String],
305 ) -> Result<Event> { 371 ) -> Result<Event> {
306 // Get relay URL from client 372 let (event, _repo_id) = self
307 let relay_url = self 373 .create_repo_announcement_custom(test_name, self.keys(), maintainer_pubkeys)
308 .client 374 .await?;
309 .relays()
310 .await
311 .keys()
312 .next()
313 .ok_or_else(|| anyhow!("No relay connected"))?
314 .to_string();
315
316 // Convert WebSocket URL to HTTP URL for clone tag
317 let http_url = relay_url
318 .replace("ws://", "http://")
319 .replace("wss://", "https://");
320
321 // Create unique repository identifier using UUID for consistency
322 let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]);
323
324 // Get npub for clone URL
325 let npub = self
326 .public_key()
327 .to_bech32()
328 .map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?;
329
330 // Build kind 30617 repository announcement with maintainers tag
331 let event = self
332 .event_builder(
333 Kind::GitRepoAnnouncement,
334 format!("Test repository for {}", test_name),
335 )
336 .tag(Tag::identifier(&repo_id))
337 .tag(Tag::custom(
338 TagKind::custom("name"),
339 vec![format!("{} Test Repository", test_name)],
340 ))
341 .tag(Tag::custom(
342 TagKind::custom("description"),
343 vec![format!("Repository for {} testing", test_name)],
344 ))
345 .tag(Tag::custom(
346 TagKind::custom("clone"),
347 vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
348 ))
349 .tag(Tag::custom(
350 TagKind::custom("relays"),
351 vec![relay_url.clone()],
352 ))
353 .tag(Tag::custom(
354 TagKind::custom("maintainers"),
355 maintainer_pubkeys.to_vec(),
356 ))
357 .build(self.keys())
358 .map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?;
359
360 Ok(event) 375 Ok(event)
361 } 376 }
362 377
@@ -465,10 +480,14 @@ mod tests {
465 fn test_event_builder() { 480 fn test_event_builder() {
466 let config = AuditConfig::ci(); 481 let config = AuditConfig::ci();
467 let keys = Keys::generate(); 482 let keys = Keys::generate();
483 let maintainer_keys = Keys::generate();
484 let recursive_maintainer_keys = Keys::generate();
468 let client = AuditClient { 485 let client = AuditClient {
469 client: Client::new(keys.clone()), 486 client: Client::new(keys.clone()),
470 config: config.clone(), 487 config: config.clone(),
471 keys: keys.clone(), 488 keys: keys.clone(),
489 maintainer_keys,
490 recursive_maintainer_keys,
472 }; 491 };
473 492
474 let _builder = client.event_builder(Kind::TextNote, "test content"); 493 let _builder = client.event_builder(Kind::TextNote, "test content");
@@ -481,10 +500,14 @@ mod tests {
481 fn test_audit_tags_automatically_added() { 500 fn test_audit_tags_automatically_added() {
482 let config = AuditConfig::ci(); 501 let config = AuditConfig::ci();
483 let keys = Keys::generate(); 502 let keys = Keys::generate();
503 let maintainer_keys = Keys::generate();
504 let recursive_maintainer_keys = Keys::generate();
484 let client = AuditClient { 505 let client = AuditClient {
485 client: Client::new(keys.clone()), 506 client: Client::new(keys.clone()),
486 config: config.clone(), 507 config: config.clone(),
487 keys: keys.clone(), 508 keys: keys.clone(),
509 maintainer_keys,
510 recursive_maintainer_keys,
488 }; 511 };
489 512
490 // Create an event with a custom tag 513 // Create an event with a custom tag
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs
index 9ccd703..f7988a0 100644
--- a/grasp-audit/src/fixtures.rs
+++ b/grasp-audit/src/fixtures.rs
@@ -28,7 +28,7 @@ use nostr_sdk::prelude::Event;
28use std::collections::HashMap; 28use std::collections::HashMap;
29use std::sync::{Arc, Mutex}; 29use std::sync::{Arc, Mutex};
30 30
31/// Deterministic commit hash used in RepoState fixtures 31/// Deterministic commit hash used in RepoState fixtures (Owner variant)
32/// This is the hash produced by creating a commit with: 32/// This is the hash produced by creating a commit with:
33/// - Message: "Initial commit" 33/// - Message: "Initial commit"
34/// - File: test.txt containing "Initial commit" 34/// - File: test.txt containing "Initial commit"
@@ -39,20 +39,101 @@ use std::sync::{Arc, Mutex};
39/// - Parent: Initial empty commit (09cc37de80f3434fa98864a86730b8d7777bd6ae) 39/// - Parent: Initial empty commit (09cc37de80f3434fa98864a86730b8d7777bd6ae)
40pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c0ce5d"; 40pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c0ce5d";
41 41
42/// Deterministic commit hash for maintainer fixtures (Maintainer variant)
43/// This is the hash produced by creating a commit with:
44/// - Message: "Maintainer initial commit"
45/// - File: test.txt containing "Maintainer initial commit"
46/// - Author date: 2024-01-01T00:00:00Z
47/// - Committer date: 2024-01-01T00:00:00Z
48/// - GPG signing: disabled
49/// - User: "GRASP Audit Test <test@grasp-audit.local>"
50/// - Parent: none (root commit)
51/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content
52pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464";
53
54/// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant)
55/// This is the hash produced by creating a commit with:
56/// - Message: "Recursive maintainer initial commit"
57/// - File: test.txt containing "Recursive maintainer initial commit"
58/// - Author date: 2024-01-01T00:00:00Z
59/// - Committer date: 2024-01-01T00:00:00Z
60/// - GPG signing: disabled
61/// - User: "GRASP Audit Test <test@grasp-audit.local>"
62/// - Parent: none (root commit)
63/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content
64pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fbdb9c077d0a64fc68522f3cb8e0";
65
42/// Types of test fixtures available 66/// Types of test fixtures available
67///
68/// ## Fixture Dependencies
69///
70/// Several fixtures depend on `ValidRepo` - they all use the SAME repo_id
71/// within a single TestContext instance to ensure proper fixture relationships:
72/// - `RepoState` → uses ValidRepo's repo_id
73/// - `MaintainerAnnouncement` + `MaintainerState` → uses ValidRepo's repo_id
74/// - `RecursiveMaintainerRepoAndState` → uses ValidRepo's repo_id
75///
76/// This enables testing recursive maintainer authorization chains where multiple
77/// parties publish announcements and state events for the same repository.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum FixtureKind { 79pub enum FixtureKind {
45 /// Basic repository announcement (kind 30617) 80 /// Basic repository announcement (kind 30617)
81 /// - Signed by owner keys (`client.keys()`)
82 /// - Lists `client.maintainer_pubkey_hex()` in maintainers tag
46 ValidRepo, 83 ValidRepo,
47 84
48 /// Repository with one issue (kind 1621) 85 /// Repository with one issue (kind 1621)
86 /// - Requires ValidRepo (reuses same repo_id)
49 RepoWithIssue, 87 RepoWithIssue,
50 88
51 /// Repository with issue and comment (kind 1111) 89 /// Repository with issue and comment (kind 1111)
90 /// - Requires RepoWithIssue (reuses same repo_id)
52 RepoWithComment, 91 RepoWithComment,
53 92
54 /// Repository state announcement (kind 30618) 93 /// Repository state announcement (kind 30618) for owner
94 /// - Requires ValidRepo (uses same repo_id)
95 /// - Signed by owner keys (`client.keys()`)
96 /// - Points to DETERMINISTIC_COMMIT_HASH
97 /// - Timestamp: 10 seconds in the past
55 RepoState, 98 RepoState,
99
100 /// Maintainer's repo announcement only for the SAME repo_id as ValidRepo
101 /// - Requires ValidRepo (uses same repo_id for maintainer chain)
102 /// - Announcement signed by `client.maintainer_keys()`
103 /// - Lists `client.recursive_maintainer_pubkey_hex()` in maintainers tag
104 /// - Does NOT include state event (use MaintainerState for that)
105 MaintainerAnnouncement,
106
107 /// Maintainer's state event only for the SAME repo_id as ValidRepo
108 /// - Requires ValidRepo (uses same repo_id for maintainer chain)
109 /// - State event signed by `client.maintainer_keys()`
110 /// - Points to MAINTAINER_DETERMINISTIC_COMMIT_HASH
111 /// - Timestamp: 5 seconds in the past (more recent than owner's state)
112 /// - Does NOT include announcement (use MaintainerAnnouncement for that)
113 MaintainerState,
114
115 /// Recursive maintainer's announcement only for the SAME repo_id as ValidRepo
116 /// - Requires ValidRepo (uses same repo_id for recursive chain)
117 /// - Announcement signed by `client.recursive_maintainer_keys()`
118 /// - Lists owner and maintainer in maintainers tag
119 /// - Does NOT include state event (use RecursiveMaintainerState for that)
120 RecursiveMaintainerAnnouncement,
121
122 /// Recursive maintainer's state event only for the SAME repo_id as ValidRepo
123 /// - Requires ValidRepo (uses same repo_id for recursive chain)
124 /// - State event signed by `client.recursive_maintainer_keys()`
125 /// - Points to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
126 /// - Timestamp: 2 seconds in the past (most recent)
127 /// - Does NOT include announcement (use RecursiveMaintainerAnnouncement for that)
128 RecursiveMaintainerState,
129
130 /// Recursive maintainer's announcement + state for the SAME repo_id as ValidRepo
131 /// - Requires ValidRepo (uses same repo_id for recursive chain)
132 /// - Announcement signed by `client.recursive_maintainer_keys()`
133 /// - Lists owner and maintainer in maintainers tag
134 /// - State event points to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
135 /// - Timestamp: 2 seconds in the past (most recent)
136 RecursiveMaintainerRepoAndState,
56} 137}
57 138
58/// Context mode for fixture management 139/// Context mode for fixture management
@@ -214,12 +295,17 @@ impl<'a> TestContext<'a> {
214 Ok(event) 295 Ok(event)
215 } 296 }
216 297
217 /// Get or create a ValidRepo, with mode-appropriate caching. 298 /// Get or create a ValidRepo, with caching within the TestContext.
218 /// This is a helper method that avoids async recursion by not going 299 /// This is a helper method that avoids async recursion by not going
219 /// through get_fixture. It handles the repo specifically. 300 /// through get_fixture. It handles the repo specifically.
301 ///
302 /// IMPORTANT: We always cache within a TestContext instance to ensure
303 /// fixture dependencies work correctly. The isolation between tests
304 /// comes from each test having its own TestContext with a fresh cache.
220 async fn get_or_create_repo(&self) -> Result<Event> { 305 async fn get_or_create_repo(&self) -> Result<Event> {
221 // In Shared mode, check cache first 306 // Always check cache first - this ensures fixture dependencies work
222 if self.mode == ContextMode::Shared { 307 // (e.g., MaintainerRepoAndState needs the SAME repo_id as RepoState)
308 {
223 let cache = self.cache.lock().unwrap(); 309 let cache = self.cache.lock().unwrap();
224 if let Some(event) = cache.get(&FixtureKind::ValidRepo) { 310 if let Some(event) = cache.get(&FixtureKind::ValidRepo) {
225 return Ok(event.clone()); 311 return Ok(event.clone());
@@ -237,8 +323,8 @@ impl<'a> TestContext<'a> {
237 // Send it 323 // Send it
238 self.client.send_event(repo.clone()).await?; 324 self.client.send_event(repo.clone()).await?;
239 325
240 // Cache it in Shared mode 326 // Always cache it - isolation comes from each test having its own TestContext
241 if self.mode == ContextMode::Shared { 327 {
242 let mut cache = self.cache.lock().unwrap(); 328 let mut cache = self.cache.lock().unwrap();
243 cache.insert(FixtureKind::ValidRepo, repo.clone()); 329 cache.insert(FixtureKind::ValidRepo, repo.clone());
244 } 330 }
@@ -246,18 +332,18 @@ impl<'a> TestContext<'a> {
246 Ok(repo) 332 Ok(repo)
247 } 333 }
248 334
249 /// Get or create a RepoWithIssue, with mode-appropriate caching. 335 /// Get or create a RepoWithIssue, with caching within the TestContext.
250 /// Returns the issue event (repo is already sent/cached via get_or_create_repo). 336 /// Returns the issue event (repo is already sent/cached via get_or_create_repo).
251 async fn get_or_create_issue(&self) -> Result<Event> { 337 async fn get_or_create_issue(&self) -> Result<Event> {
252 // In Shared mode, check cache first 338 // Always check cache first - ensures fixture dependencies work
253 if self.mode == ContextMode::Shared { 339 {
254 let cache = self.cache.lock().unwrap(); 340 let cache = self.cache.lock().unwrap();
255 if let Some(event) = cache.get(&FixtureKind::RepoWithIssue) { 341 if let Some(event) = cache.get(&FixtureKind::RepoWithIssue) {
256 return Ok(event.clone()); 342 return Ok(event.clone());
257 } 343 }
258 } 344 }
259 345
260 // Get or create repo (reuses cached in Shared mode) 346 // Get or create repo (reuses cached within this TestContext)
261 let repo = self.get_or_create_repo().await?; 347 let repo = self.get_or_create_repo().await?;
262 348
263 // Create the issue 349 // Create the issue
@@ -271,8 +357,8 @@ impl<'a> TestContext<'a> {
271 // Send it 357 // Send it
272 self.client.send_event(issue.clone()).await?; 358 self.client.send_event(issue.clone()).await?;
273 359
274 // Cache it in Shared mode 360 // Always cache it - isolation comes from each test having its own TestContext
275 if self.mode == ContextMode::Shared { 361 {
276 let mut cache = self.cache.lock().unwrap(); 362 let mut cache = self.cache.lock().unwrap();
277 cache.insert(FixtureKind::RepoWithIssue, issue.clone()); 363 cache.insert(FixtureKind::RepoWithIssue, issue.clone());
278 } 364 }
@@ -284,12 +370,8 @@ impl<'a> TestContext<'a> {
284 async fn build_fixture(&self, kind: FixtureKind) -> Result<Event> { 370 async fn build_fixture(&self, kind: FixtureKind) -> Result<Event> {
285 match kind { 371 match kind {
286 FixtureKind::ValidRepo => { 372 FixtureKind::ValidRepo => {
287 let test_name = format!( 373 // Delegate to get_or_create_repo() which handles caching properly.
288 "fixture-{:?}-{}", 374 self.get_or_create_repo().await
289 kind,
290 &uuid::Uuid::new_v4().to_string()[..8]
291 );
292 self.client.create_repo_announcement(&test_name).await
293 } 375 }
294 376
295 FixtureKind::RepoWithIssue => { 377 FixtureKind::RepoWithIssue => {
@@ -340,6 +422,9 @@ impl<'a> TestContext<'a> {
340 .to_string(); 422 .to_string();
341 423
342 // Create state announcement with deterministic commit hash 424 // Create state announcement with deterministic commit hash
425 let base_time = Timestamp::now().as_u64();
426 let older_timestamp = Timestamp::from(base_time - 10); // 10 seconds ago
427
343 // Tag format: ["refs/heads/main", "<commit_hash>"] 428 // Tag format: ["refs/heads/main", "<commit_hash>"]
344 // Note: We build the state but DON'T send it here - the caller will send it 429 // Note: We build the state but DON'T send it here - the caller will send it
345 self.client 430 self.client
@@ -353,12 +438,267 @@ impl<'a> TestContext<'a> {
353 TagKind::custom("HEAD"), 438 TagKind::custom("HEAD"),
354 vec!["ref: refs/heads/main".to_string()], 439 vec!["ref: refs/heads/main".to_string()],
355 )) 440 ))
441 .custom_time(older_timestamp)
356 .build(self.client.keys()) 442 .build(self.client.keys())
357 .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e)) 443 .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))
358 } 444 }
445
446 FixtureKind::MaintainerAnnouncement => {
447 use nostr_sdk::prelude::*;
448
449 // Get the owner's repo to use the SAME repo_id
450 let owner_repo = self.get_or_create_repo().await?;
451
452 // Extract repo_id from owner's repo announcement
453 let repo_id = owner_repo
454 .tags
455 .iter()
456 .find(|t| t.kind() == TagKind::d())
457 .and_then(|t| t.content())
458 .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))?
459 .to_string();
460
461 self.build_maintainer_announcement(&repo_id).await
462 }
463
464 FixtureKind::MaintainerState => {
465 use nostr_sdk::prelude::*;
466
467 // Get the owner's repo to use the SAME repo_id
468 let owner_repo = self.get_or_create_repo().await?;
469
470 // Extract repo_id from owner's repo announcement
471 let repo_id = owner_repo
472 .tags
473 .iter()
474 .find(|t| t.kind() == TagKind::d())
475 .and_then(|t| t.content())
476 .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))?
477 .to_string();
478
479 // Build state event ONLY - does NOT send announcement
480 // This allows testing state-only scenarios
481 self.build_maintainer_state(&repo_id)
482 }
483
484 FixtureKind::RecursiveMaintainerAnnouncement => {
485 use nostr_sdk::prelude::*;
486
487 // Get the owner's repo to use the SAME repo_id
488 let owner_repo = self.get_or_create_repo().await?;
489
490 // Extract repo_id from owner's repo announcement
491 let repo_id = owner_repo
492 .tags
493 .iter()
494 .find(|t| t.kind() == TagKind::d())
495 .and_then(|t| t.content())
496 .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))?
497 .to_string();
498
499 self.build_recursive_maintainer_announcement(&repo_id).await
500 }
501
502 FixtureKind::RecursiveMaintainerState => {
503 use nostr_sdk::prelude::*;
504
505 // Get the owner's repo to use the SAME repo_id
506 let owner_repo = self.get_or_create_repo().await?;
507
508 // Extract repo_id from owner's repo announcement
509 let repo_id = owner_repo
510 .tags
511 .iter()
512 .find(|t| t.kind() == TagKind::d())
513 .and_then(|t| t.content())
514 .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))?
515 .to_string();
516
517 // Build state event ONLY - does NOT send announcement
518 self.build_recursive_maintainer_state(&repo_id)
519 }
520
521 FixtureKind::RecursiveMaintainerRepoAndState => {
522 use nostr_sdk::prelude::*;
523
524 // Get the owner's repo to use the SAME repo_id
525 let owner_repo = self.get_or_create_repo().await?;
526
527 // Extract repo_id from owner's repo announcement
528 let repo_id = owner_repo
529 .tags
530 .iter()
531 .find(|t| t.kind() == TagKind::d())
532 .and_then(|t| t.content())
533 .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))?
534 .to_string();
535
536 // Build and send the recursive maintainer's repo announcement
537 let recursive_maintainer_announcement = self.build_recursive_maintainer_announcement(&repo_id).await?;
538 self.client.send_event(recursive_maintainer_announcement).await?;
539
540 // Return the state event (caller will send it)
541 self.build_recursive_maintainer_state(&repo_id)
542 }
359 } 543 }
360 } 544 }
361 545
546 /// Build maintainer announcement event for the given repo_id
547 async fn build_maintainer_announcement(&self, repo_id: &str) -> Result<Event> {
548 use nostr_sdk::prelude::*;
549
550 // Get relay URL for clone tag
551 let relay_url = self.client
552 .client()
553 .relays()
554 .await
555 .keys()
556 .next()
557 .ok_or_else(|| anyhow::anyhow!("No relay connected"))?
558 .to_string();
559 let http_url = relay_url
560 .replace("ws://", "http://")
561 .replace("wss://", "https://");
562
563 // Create maintainer's repo announcement for the SAME repo_id
564 let maintainer_npub = self.client
565 .maintainer_keys()
566 .public_key()
567 .to_bech32()
568 .map_err(|e| anyhow::anyhow!("Failed to convert maintainer pubkey: {}", e))?;
569
570 self.client
571 .event_builder(
572 Kind::GitRepoAnnouncement,
573 format!("Maintainer announcement for {}", repo_id),
574 )
575 .tag(Tag::identifier(repo_id))
576 .tag(Tag::custom(
577 TagKind::custom("name"),
578 vec![format!("{} (maintainer)", repo_id)],
579 ))
580 .tag(Tag::custom(
581 TagKind::custom("clone"),
582 vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)],
583 ))
584 .tag(Tag::custom(
585 TagKind::custom("relays"),
586 vec![relay_url],
587 ))
588 .tag(Tag::custom(
589 TagKind::custom("maintainers"),
590 vec![self.client.recursive_maintainer_pubkey_hex()],
591 ))
592 .build(self.client.maintainer_keys())
593 .map_err(|e| anyhow::anyhow!("Failed to build maintainer repo announcement: {}", e))
594 }
595
596 /// Build maintainer state event for the given repo_id
597 fn build_maintainer_state(&self, repo_id: &str) -> Result<Event> {
598 use nostr_sdk::prelude::*;
599
600 // Create state announcement 5 seconds in the past, signed by maintainer
601 let base_time = Timestamp::now().as_u64();
602 let older_timestamp = Timestamp::from(base_time - 5); // 5 seconds ago
603
604 self.client
605 .event_builder(Kind::Custom(30618), "")
606 .tag(Tag::identifier(repo_id))
607 .tag(Tag::custom(
608 TagKind::custom("refs/heads/main"),
609 vec![MAINTAINER_DETERMINISTIC_COMMIT_HASH.to_string()],
610 ))
611 .tag(Tag::custom(
612 TagKind::custom("HEAD"),
613 vec!["ref: refs/heads/main".to_string()],
614 ))
615 .custom_time(older_timestamp)
616 .build(self.client.maintainer_keys())
617 .map_err(|e| anyhow::anyhow!("Failed to build maintainer state announcement: {}", e))
618 }
619
620 /// Build recursive maintainer announcement event for the given repo_id
621 async fn build_recursive_maintainer_announcement(&self, repo_id: &str) -> Result<Event> {
622 use nostr_sdk::prelude::*;
623
624 // Get relay URL for clone tag
625 let relay_url = self.client
626 .client()
627 .relays()
628 .await
629 .keys()
630 .next()
631 .ok_or_else(|| anyhow::anyhow!("No relay connected"))?
632 .to_string();
633 let http_url = relay_url
634 .replace("ws://", "http://")
635 .replace("wss://", "https://");
636
637 // Create recursive maintainer's repo announcement for the SAME repo_id
638 let recursive_maintainer_npub = self.client
639 .recursive_maintainer_keys()
640 .public_key()
641 .to_bech32()
642 .map_err(|e| anyhow::anyhow!("Failed to convert recursive maintainer pubkey: {}", e))?;
643
644 self.client
645 .event_builder(
646 Kind::GitRepoAnnouncement,
647 format!("Recursive maintainer announcement for {}", repo_id),
648 )
649 .tag(Tag::identifier(repo_id))
650 .tag(Tag::custom(
651 TagKind::custom("name"),
652 vec![format!("{} (recursive maintainer)", repo_id)],
653 ))
654 .tag(Tag::custom(
655 TagKind::custom("clone"),
656 vec![format!("{}/{}/{}.git", http_url, recursive_maintainer_npub, repo_id)],
657 ))
658 .tag(Tag::custom(
659 TagKind::custom("relays"),
660 vec![relay_url],
661 ))
662 .tag(Tag::custom(
663 TagKind::custom("maintainers"),
664 vec![
665 self.client.public_key().to_hex(),
666 self.client.maintainer_pubkey_hex(),
667 ],
668 ))
669 .build(self.client.recursive_maintainer_keys())
670 .map_err(|e| anyhow::anyhow!("Failed to build recursive maintainer repo announcement: {}", e))
671 }
672
673 /// Build recursive maintainer state event for the given repo_id
674 fn build_recursive_maintainer_state(&self, repo_id: &str) -> Result<Event> {
675 use nostr_sdk::prelude::*;
676
677 // Create state announcement 2 seconds in the past, signed by recursive maintainer
678 let base_time = Timestamp::now().as_u64();
679 let older_timestamp = Timestamp::from(base_time - 2); // 2 seconds ago
680
681 self.client
682 .event_builder(Kind::Custom(30618), "")
683 .tag(Tag::identifier(repo_id))
684 .tag(Tag::custom(
685 TagKind::custom("refs/heads/main"),
686 vec![RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH.to_string()],
687 ))
688 .tag(Tag::custom(
689 TagKind::custom("HEAD"),
690 vec!["ref: refs/heads/main".to_string()],
691 ))
692 .custom_time(older_timestamp)
693 .build(self.client.recursive_maintainer_keys())
694 .map_err(|e| {
695 anyhow::anyhow!(
696 "Failed to build recursive maintainer state announcement: {}",
697 e
698 )
699 })
700 }
701
362 /// Clear the fixture cache 702 /// Clear the fixture cache
363 /// 703 ///
364 /// This is useful for tests that want to ensure fresh fixtures 704 /// This is useful for tests that want to ensure fresh fixtures
diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs
index deed2d5..b7ce992 100644
--- a/grasp-audit/src/lib.rs
+++ b/grasp-audit/src/lib.rs
@@ -37,7 +37,10 @@ pub mod specs;
37 37
38pub use audit::{AuditConfig, AuditMode}; 38pub use audit::{AuditConfig, AuditMode};
39pub use client::AuditClient; 39pub use client::AuditClient;
40pub use fixtures::{ContextMode, FixtureKind, TestContext, DETERMINISTIC_COMMIT_HASH}; 40pub use fixtures::{
41 ContextMode, FixtureKind, TestContext, DETERMINISTIC_COMMIT_HASH,
42 MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH,
43};
41pub use result::{AuditResult, TestResult}; 44pub use result::{AuditResult, TestResult};
42 45
43// Re-export commonly used types 46// Re-export commonly used types
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)]
diff --git a/tests/push_authorization.rs b/tests/push_authorization.rs
index 0dc802f..152754f 100644
--- a/tests/push_authorization.rs
+++ b/tests/push_authorization.rs
@@ -65,5 +65,6 @@ macro_rules! isolated_push_test {
65isolated_push_test!(test_push_authorized_by_owner_state); 65isolated_push_test!(test_push_authorized_by_owner_state);
66isolated_push_test!(test_push_rejected_without_state_event); 66isolated_push_test!(test_push_rejected_without_state_event);
67isolated_push_test!(test_push_rejected_wrong_commit); 67isolated_push_test!(test_push_rejected_wrong_commit);
68isolated_push_test!(test_latest_state_event_used); 68isolated_push_test!(test_push_authorized_by_direct_maintainer_state);
69isolated_push_test!(test_push_authorized_by_recursive_maintainer_state);
69isolated_push_test!(test_non_maintainer_state_rejected); \ No newline at end of file 70isolated_push_test!(test_non_maintainer_state_rejected); \ No newline at end of file