1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
|
//! Audit client for testing GRASP implementations
use crate::audit::{AuditConfig, AuditEventBuilder, AuditMode};
use crate::fixtures::FixtureKind;
use anyhow::{anyhow, Context, Result};
use nostr_sdk::prelude::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Type alias for the fixture cache - shared across TestContext instances
pub type FixtureCache = Arc<Mutex<HashMap<FixtureKind, Event>>>;
/// Client for auditing GRASP implementations
///
/// The AuditClient owns a fixture cache that is shared across all TestContext
/// instances created from this client. This provides natural cache sharing:
/// - CLI creates one AuditClient → fixtures shared across all tests
/// - cargo test creates one AuditClient per test → fixtures isolated per test
pub struct AuditClient {
client: Client,
pub config: AuditConfig,
keys: Keys,
/// Maintainer keys for testing push authorization scenarios
maintainer_keys: Keys,
/// Recursive maintainer keys for testing recursive authorization scenarios
recursive_maintainer_keys: Keys,
/// PR author keys for testing PR event scenarios
pr_author_keys: Keys,
/// Fixture cache for TestContext instances - shared across all contexts using this client
fixture_cache: FixtureCache,
}
impl AuditClient {
/// Create a new audit client for testing (no relay connection)
#[cfg(test)]
pub fn new_test(config: AuditConfig) -> Self {
let keys = Keys::generate();
let maintainer_keys = Keys::generate();
let recursive_maintainer_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let client = Client::new(keys.clone());
Self {
client,
config,
keys,
maintainer_keys,
recursive_maintainer_keys,
pr_author_keys,
fixture_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Create a new audit client
pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> {
let keys = Keys::generate();
let maintainer_keys = Keys::generate();
let recursive_maintainer_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let client = Client::new(keys.clone());
// Add relay and connect
client.add_relay(relay_url).await?;
client.connect().await;
// Wait for connection to establish (with retries)
let mut attempts = 0;
let mut connected = false;
while attempts < 20 {
tokio::time::sleep(Duration::from_millis(100)).await;
let relays = client.relays().await;
connected = relays.values().any(|r| r.is_connected());
if connected {
break;
}
attempts += 1;
}
// Verify we actually connected
if !connected {
return Err(anyhow!(
"Failed to connect to relay at '{}'\n\
\n\
Possible causes:\n\
• Relay is not running at this address\n\
• Network connectivity issues\n\
• Incorrect URL or port\n\
\n\
To start ngit-relay for testing:\n\
docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest\n\
\n\
Or use the test script:\n\
cd grasp-audit && ./test-ngit-relay.sh",
relay_url
));
}
// Give it a bit more time to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(Self {
client,
config,
keys,
maintainer_keys,
recursive_maintainer_keys,
pr_author_keys,
fixture_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
/// Get the fixture cache for TestContext usage
///
/// This cache is shared across all TestContext instances created from this client.
/// In CLI mode (one client for all tests), fixtures are reused.
/// In test mode (one client per test), fixtures are isolated.
pub fn fixture_cache(&self) -> &FixtureCache {
&self.fixture_cache
}
/// Get the public key for this audit client
pub fn public_key(&self) -> PublicKey {
self.keys.public_key()
}
/// Get the relay URL
pub async fn relay_url(&self) -> Result<String> {
let relays = self.client.relays().await;
let relay = relays
.values()
.next()
.ok_or_else(|| anyhow!("No relays configured"))?;
Ok(relay.url().to_string())
}
/// Convert WebSocket URL to HTTP(S) URL for NIP-11 requests
pub fn ws_to_http_url(ws_url: &str) -> Result<String> {
if ws_url.starts_with("ws://") {
Ok(ws_url.replace("ws://", "http://"))
} else if ws_url.starts_with("wss://") {
Ok(ws_url.replace("wss://", "https://"))
} else {
Err(anyhow!("Invalid WebSocket URL: {}", ws_url))
}
}
/// Check if connected to relay
pub async fn is_connected(&self) -> bool {
// Check if we have any connected relays
let relays = self.client.relays().await;
for relay in relays.values() {
if relay.is_connected() {
return true;
}
}
false
}
/// Send an event (with audit tags automatically added)
pub async fn send_event(&self, event: Event) -> Result<EventId> {
if self.config.read_only {
return Err(anyhow!("Client is in read-only mode"));
}
let output = self.client.send_event(&event).await?;
let event_id = *output.id();
// Check if any relay rejected the event and return the error message
if !output.failed.is_empty() {
// Get the first failed relay error message
let (relay_url, error) = output.failed.iter().next().unwrap();
return Err(anyhow!("Relay {} rejected event: {}", relay_url, error));
}
// Wait a bit for event to propagate
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(event_id)
}
/// Send an event (with audit tags automatically added) and expect OK, success, 'purgatory:', verify not served - TODO the msg verificaiton is not implemented
pub async fn send_event_expect_purgatory_not_served(&self, event: Event) -> Result<EventId> {
if self.config.read_only {
return Err(anyhow!("Client is in read-only mode"));
}
let output = self.client.send_event(&event).await?;
let event_id = *output.id();
// Check if any relay rejected the event and return the error message
if !output.failed.is_empty() {
// Get the first failed relay error message
let (relay_url, error) = output.failed.iter().next().unwrap();
return Err(anyhow!("Relay {} rejected event: {}", relay_url, error));
}
// Wait a bit for event to propagate
tokio::time::sleep(Duration::from_millis(300)).await;
if self.is_event_on_relay(event.id).await? {
return Err(anyhow!(
"event sent to relay was served instead of being put in purgatory"
));
}
Ok(event_id)
}
/// Send event and note whether it entered purgatory (not served) or was served immediately.
///
/// This is a tolerant version of `send_event_expect_purgatory_not_served` that doesn't
/// fail if purgatory is not observed. It returns whether purgatory was observed so
/// fixtures can proceed regardless of relay implementation status.
///
/// Returns (EventId, bool) where bool = true if event was NOT served (purgatory observed).
pub async fn send_event_and_note_purgatory(&self, event: Event) -> Result<(EventId, bool)> {
if self.config.read_only {
return Err(anyhow!("Client is in read-only mode"));
}
let output = self.client.send_event(&event).await?;
let event_id = *output.id();
// Check if any relay rejected the event and return the error message
if !output.failed.is_empty() {
let (relay_url, error) = output.failed.iter().next().unwrap();
return Err(anyhow!("Relay {} rejected event: {}", relay_url, error));
}
// Wait a bit for event to propagate
tokio::time::sleep(Duration::from_millis(300)).await;
// Check if event is served (not in purgatory) or not served (in purgatory)
let in_purgatory = !self.is_event_on_relay(event.id).await?;
Ok((event_id, in_purgatory))
}
/// check if an event is on the relay
pub async fn is_event_on_relay(&self, id: EventId) -> Result<bool> {
Ok(!self
.client
.fetch_events(vec![Filter::new().id(id)], Duration::from_secs(1))
.await
.context("error trying to query relay for event")?
.is_empty())
}
/// Create an event builder that automatically includes audit tags
///
/// All events built through this method will automatically have audit tags appended
/// when you call `.build()`. These tags provide isolation, cleanup scheduling, and
/// easy discovery of audit events.
///
/// # Automatic Tags Added
///
/// When you call `.build()` on the returned builder, these tags will be automatically added:
/// - `["t", "grasp-audit-test-event"]` - Identifies all audit events
/// - `["t", "audit-{run_id}"]` - Unique ID for this audit run
/// - `["t", "audit-cleanup-after-{timestamp}"]` - Cleanup scheduling
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example() -> anyhow::Result<()> {
/// let config = AuditConfig::shared();
/// let client = AuditClient::new("ws://localhost:7000", config).await?;
///
/// // Create event with automatic audit tags
/// let event = client.event_builder(Kind::TextNote, "test content")
/// .tag(Tag::custom(TagKind::custom("custom"), vec!["value"]))
/// .build(client.keys())?;
///
/// // Event now has both your custom tag AND the 3 audit tags
/// # Ok(())
/// # }
/// ```
///
/// See [`AuditConfig::audit_tags()`] for details on the tag format.
pub fn event_builder(&self, kind: Kind, content: impl Into<String>) -> AuditEventBuilder {
AuditEventBuilder::new(kind, content, self.config.clone())
}
/// Query events, optionally filtered to this audit run
pub async fn query(&self, mut filter: Filter) -> Result<Vec<Event>> {
use nostr_sdk::prelude::{Alphabet, SingleLetterTag};
if self.config.mode == AuditMode::Isolated {
// In Isolated mode, only see our own audit events
// Filter by "t" tags (hashtags)
let t_tag = SingleLetterTag::lowercase(Alphabet::T);
filter = filter
.custom_tag(t_tag, "grasp-audit-test-event")
.custom_tag(t_tag, format!("audit-{}", self.config.run_id));
}
// In Production mode, see all events (no filter modification)
let events = self
.client
.fetch_events(filter, Duration::from_secs(5))
.await?;
Ok(events.into_iter().collect())
}
/// Subscribe to events with a callback
pub async fn subscribe(
&self,
filters: Vec<Filter>,
timeout: Option<Duration>,
) -> Result<Vec<Event>> {
let timeout = timeout.unwrap_or(Duration::from_secs(5));
let mut all_events = Vec::new();
for filter in filters {
let events = self.client.fetch_events(filter, timeout).await?;
all_events.extend(events.into_iter());
}
Ok(all_events)
}
/// Get the underlying nostr client (for advanced usage)
pub fn client(&self) -> &Client {
&self.client
}
/// Get the keys (for signing custom events)
pub fn keys(&self) -> &Keys {
&self.keys
}
/// Get the maintainer keys (for push authorization testing)
pub fn maintainer_keys(&self) -> &Keys {
&self.maintainer_keys
}
/// Get the maintainer public key as a hex string
pub fn maintainer_pubkey_hex(&self) -> String {
self.maintainer_keys.public_key().to_hex()
}
/// Get the recursive maintainer keys (for recursive authorization testing)
pub fn recursive_maintainer_keys(&self) -> &Keys {
&self.recursive_maintainer_keys
}
/// Get the recursive maintainer public key as a hex string
pub fn recursive_maintainer_pubkey_hex(&self) -> String {
self.recursive_maintainer_keys.public_key().to_hex()
}
/// Get the PR author keys (for PR event testing)
pub fn pr_author_keys(&self) -> &Keys {
&self.pr_author_keys
}
/// Get the PR author public key as a hex string
pub fn pr_author_pubkey_hex(&self) -> String {
self.pr_author_keys.public_key().to_hex()
}
/// Create a NIP-34 repository announcement event with full customization
///
/// This is the core method for creating repository announcements. It allows
/// specifying the signing keys and maintainers, making it suitable for all
/// repo creation scenarios including maintainer and recursive maintainer testing.
///
/// # Arguments
/// * `test_name` - Name of the test (used to create unique repo identifier)
/// * `signing_keys` - The keys to sign the event with (also used for clone URL)
/// * `maintainer_pubkeys` - Hex pubkeys of maintainers who can push to the repository
///
/// # Returns
/// A tuple of (Event, repo_id) - the built event and the repository identifier
pub async fn create_repo_announcement_custom(
&self,
test_name: &str,
signing_keys: &Keys,
maintainer_pubkeys: &[String],
) -> Result<(Event, String)> {
// Get relay URL from client
let relay_url = self
.client
.relays()
.await
.keys()
.next()
.ok_or_else(|| anyhow!("No relay connected"))?
.to_string();
// Convert WebSocket URL to HTTP URL for clone tag
let http_url = relay_url
.replace("ws://", "http://")
.replace("wss://", "https://");
// Create unique repository identifier using UUID for consistency
let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]);
// Get npub for clone URL from signing keys
let npub = signing_keys
.public_key()
.to_bech32()
.map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?;
// Build kind 30617 repository announcement
let event = self
.event_builder(
Kind::GitRepoAnnouncement,
format!("Test repository for {}", test_name),
)
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("name"),
vec![format!("{} Test Repository", test_name)],
))
.tag(Tag::custom(
TagKind::custom("description"),
vec![format!("Repository for {} testing", test_name)],
))
.tag(Tag::custom(
TagKind::custom("clone"),
vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
))
.tag(Tag::custom(
TagKind::custom("relays"),
vec![relay_url.clone()],
))
.tag(Tag::custom(
TagKind::custom("maintainers"),
maintainer_pubkeys.to_vec(),
))
.build(signing_keys)
.map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?;
Ok((event, repo_id))
}
/// Create a NIP-34 repository announcement event with the client's maintainer
///
/// This helper creates a properly formatted NIP-34 announcement that will be
/// accepted by GRASP relays (which require events to list the relay in clone/relays tags).
/// The client's maintainer key is automatically added to the maintainers tag.
///
/// # Arguments
/// * `test_name` - Name of the test (used to create unique repo identifier)
///
/// # Returns
/// A built and signed Event ready to be sent to the relay
pub async fn create_repo_announcement(&self, test_name: &str) -> Result<Event> {
let (event, _repo_id) = self
.create_repo_announcement_custom(
test_name,
self.keys(),
&[self.maintainer_pubkey_hex()],
)
.await?;
Ok(event)
}
/// Create a NIP-34 repository announcement event with maintainers
///
/// This helper creates a properly formatted NIP-34 announcement that will be
/// accepted by GRASP relays (which require events to list the relay in clone/relays tags).
/// This variant also includes a maintainers tag for push authorization testing.
///
/// # Arguments
/// * `test_name` - Name of the test (used to create unique repo identifier)
/// * `maintainer_pubkeys` - Hex pubkeys of maintainers who can push to the repository
///
/// # Returns
/// A built and signed Event ready to be sent to the relay
pub async fn create_repo_announcement_with_maintainers(
&self,
test_name: &str,
maintainer_pubkeys: &[String],
) -> Result<Event> {
let (event, _repo_id) = self
.create_repo_announcement_custom(test_name, self.keys(), maintainer_pubkeys)
.await?;
Ok(event)
}
/// Create an issue (kind 1621) that references a repository
///
/// # Arguments
/// * `repo_event` - The repository announcement event to reference
/// * `issue_title` - The subject/title of the issue
/// * `content` - The issue content/description
/// * `additional_tags` - Optional additional tags (e.g., for quoting other events)
///
/// # Returns
/// A built and signed Event ready to be sent to the relay
pub fn create_issue(
&self,
repo_event: &Event,
issue_title: &str,
content: &str,
additional_tags: Vec<Tag>,
) -> Result<Event> {
// Extract repo_id from the d tag
let repo_id = repo_event
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow!("Repository event must have a 'd' tag"))?
.to_string();
let repo_pubkey = repo_event.pubkey;
let a_tag_value = format!("30617:{}:{}", repo_pubkey, repo_id);
let mut tags = vec![
Tag::custom(TagKind::custom("a"), vec![a_tag_value]),
Tag::custom(TagKind::custom("subject"), vec![issue_title]),
];
// Add any additional tags
tags.extend(additional_tags);
self.event_builder(Kind::GitIssue, content)
.tags(tags)
.build(self.keys())
.map_err(|e| anyhow!("Failed to build issue event: {}", e))
}
/// Create a NIP-22 comment (kind 1111) for an event
///
/// # Arguments
/// * `event` - The event to comment on
/// * `content` - The comment content
/// * `additional_tags` - Optional additional tags
///
/// # Returns
/// A built and signed Event ready to be sent to the relay
pub fn create_comment(
&self,
event: &Event,
content: &str,
additional_tags: Vec<Tag>,
) -> Result<Event> {
let event_kind = event.kind;
let event_pubkey = event.pubkey;
let event_id = event.id;
let mut tags = vec![
Tag::custom(
TagKind::custom("E"),
vec![event_id.to_hex(), "".to_string(), "root".to_string()],
),
Tag::event(event_id),
Tag::custom(TagKind::custom("K"), vec![event_kind.as_u16().to_string()]),
Tag::public_key(event_pubkey),
];
// Add any additional tags
tags.extend(additional_tags);
self.event_builder(Kind::Comment, content)
.tags(tags)
.build(self.keys())
.map_err(|e| anyhow!("Failed to build comment event: {}", e))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_client_creation() {
let config = AuditConfig::isolated();
// This will fail if no relay is running, which is expected in tests
// In real usage, there should be a relay at the URL
let result = AuditClient::new("ws://localhost:7000", config).await;
// We can't test connection without a running relay
// But we can test that the client is created
if let Ok(client) = result {
assert_eq!(client.config.mode, AuditMode::Isolated);
}
}
#[test]
fn test_event_builder() {
let config = AuditConfig::isolated();
let keys = Keys::generate();
let maintainer_keys = Keys::generate();
let recursive_maintainer_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let client = AuditClient {
client: Client::new(keys.clone()),
config: config.clone(),
keys: keys.clone(),
maintainer_keys,
recursive_maintainer_keys,
pr_author_keys,
fixture_cache: Arc::new(Mutex::new(HashMap::new())),
};
let _builder = client.event_builder(Kind::TextNote, "test content");
// Builder should be created successfully
// (We can't test the internal config field as it's private, which is correct)
}
#[test]
fn test_audit_tags_automatically_added() {
let config = AuditConfig::isolated();
let keys = Keys::generate();
let maintainer_keys = Keys::generate();
let recursive_maintainer_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let client = AuditClient {
client: Client::new(keys.clone()),
config: config.clone(),
keys: keys.clone(),
maintainer_keys,
recursive_maintainer_keys,
pr_author_keys,
fixture_cache: Arc::new(Mutex::new(HashMap::new())),
};
// Create an event with a custom tag
let event = client
.event_builder(Kind::TextNote, "test content")
.tag(Tag::custom(TagKind::custom("custom"), vec!["value"]))
.build(&keys)
.unwrap();
// Should have custom tag (1) + 3 audit tags = at least 4 tags
assert!(
event.tags.len() >= 4,
"Expected at least 4 tags, got {}",
event.tags.len()
);
// Verify audit tags are present by checking tag content
let tag_contents: Vec<String> = event
.tags
.iter()
.filter_map(|t| t.content().map(|s| s.to_string()))
.collect();
// Check for the three required audit tags
assert!(
tag_contents.contains(&"grasp-audit-test-event".to_string()),
"Missing 'grasp-audit-test-event' tag"
);
assert!(
tag_contents
.iter()
.any(|t| t.starts_with("audit-isolated-")),
"Missing 'audit-isolated-*' tag"
);
assert!(
tag_contents
.iter()
.any(|t| t.starts_with("audit-cleanup-after-")),
"Missing 'audit-cleanup-after-*' tag"
);
// Verify the custom tag is also present
assert!(
tag_contents.contains(&"value".to_string()),
"Missing custom tag value"
);
}
#[tokio::test]
async fn test_create_repo_announcement_with_maintainers() {
let config = AuditConfig::isolated();
let client = AuditClient::new_test(config);
// Create test maintainer pubkeys (hex format)
let maintainer_pubkeys = vec![
"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(),
"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3".to_string(),
];
// Note: We can't test create_repo_announcement_with_maintainers directly in unit tests
// because it requires a connected relay. Instead, we test the underlying event building
// with maintainers tag to verify the tag format is correct.
// Build an event with maintainers tag directly to test the tag format
let event = client
.event_builder(Kind::GitRepoAnnouncement, "Test repository")
.tag(Tag::identifier("test-repo"))
.tag(Tag::custom(
TagKind::custom("maintainers"),
maintainer_pubkeys.clone(),
))
.build(client.keys())
.unwrap();
// Verify the maintainers tag is present and correctly formatted
let maintainers_tag = event
.tags
.iter()
.find(|t| t.kind() == TagKind::custom("maintainers"));
assert!(
maintainers_tag.is_some(),
"Missing 'maintainers' tag in event"
);
// Verify the tag contains the maintainer pubkeys
let tag = maintainers_tag.unwrap();
let tag_vec: Vec<String> = tag.clone().to_vec();
// First element is "maintainers", rest are the pubkeys
assert_eq!(tag_vec[0], "maintainers");
assert_eq!(
tag_vec.len(),
3,
"Expected 3 elements: tag name + 2 pubkeys"
);
assert_eq!(tag_vec[1], maintainer_pubkeys[0]);
assert_eq!(tag_vec[2], maintainer_pubkeys[1]);
}
}
|