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-04 14:33:18 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-04 14:33:18 +0000
commitc2c0cdba4af434043f3fa707231d8f5a7e3fd882 (patch)
tree02adae65fba476e5fcdf1fadcd0ce9efa4f1fbcd
parent4d89f4537c325f60571cc6339df0708ee8161514 (diff)
add announcement tests
-rw-r--r--README.md70
-rw-r--r--src/nostr/events.rs606
-rw-r--r--src/nostr/mod.rs1
-rw-r--r--src/nostr/relay.rs32
-rw-r--r--src/storage/mod.rs6
-rw-r--r--tests/announcement_tests.rs411
6 files changed, 1113 insertions, 13 deletions
diff --git a/README.md b/README.md
index 920d1d5..0d454bc 100644
--- a/README.md
+++ b/README.md
@@ -76,17 +76,22 @@ This approach provides:
76git clone https://gitworkshop.dev/ngit-grasp 76git clone https://gitworkshop.dev/ngit-grasp
77cd ngit-grasp 77cd ngit-grasp
78 78
79# Build 79# Build (using Nix for reproducible environment)
80cargo build --release 80nix develop -c cargo build --release
81 81
82# Configure 82# Configure
83cp .env.example .env 83cp .env.example .env
84# Edit .env with your settings 84# Edit .env with your settings
85 85
86# Run 86# Run
87cargo run --release 87nix develop -c cargo run --release
88
89# Run tests
90nix develop -c cargo test --lib
88``` 91```
89 92
93**Don't have Nix?** See [Getting Started Tutorial](docs/tutorials/getting-started.md) for alternative setup methods.
94
90## Configuration 95## Configuration
91 96
92Environment variables (see `.env.example`): 97Environment variables (see `.env.example`):
@@ -114,24 +119,65 @@ We use the **[Diátaxis](https://diataxis.fr/)** framework for documentation:
114 119
115See [Architecture Overview](docs/explanation/architecture.md) for system design and [Test Strategy](docs/reference/test-strategy.md) for testing approach. 120See [Architecture Overview](docs/explanation/architecture.md) for system design and [Test Strategy](docs/reference/test-strategy.md) for testing approach.
116 121
122### Running Tests
123
124We have two test suites:
125
126**1. Main Project Tests (ngit-grasp)**
127
117```bash 128```bash
118# Run tests 129# Run unit tests (no external dependencies)
119cargo test 130nix develop -c cargo test --lib
131
132# Run integration tests (tests our relay implementation)
133# First, start ngit-grasp relay in one terminal:
134NGIT_BIND_ADDRESS=127.0.0.1:7000 nix develop -c cargo run
135
136# Then in another terminal, run integration tests:
137nix develop -c cargo test --test announcement_tests --ignored
138
139# Or use the test script (starts relay automatically):
140./test_relay.sh
141```
120 142
121# Run compliance tests 143**2. GRASP Audit Tool (grasp-audit)**
122cargo test --test compliance
123 144
145The audit tool tests GRASP compliance of any relay (including ours or external ones).
146
147```bash
148# Enter grasp-audit directory
149cd grasp-audit
150
151# Run unit tests
152nix develop -c cargo test
153
154# Test against our ngit-grasp relay:
155# First, start ngit-grasp (in another terminal):
156cd .. && NGIT_BIND_ADDRESS=127.0.0.1:7000 nix develop -c cargo run
157
158# Then run audit:
159nix develop -c cargo run -- --url ws://127.0.0.1:7000
160
161# Or test against any external relay:
162nix develop -c cargo run -- --url wss://relay.example.com
163```
164
165### Development Commands
166
167```bash
124# Run with logging 168# Run with logging
125RUST_LOG=debug cargo run 169RUST_LOG=debug nix develop -c cargo run
126 170
127# Check code 171# Check code
128cargo clippy 172nix develop -c cargo clippy
129cargo fmt --check 173nix develop -c cargo fmt --check
130 174
131# Generate test coverage 175# Generate test coverage (requires tarpaulin)
132cargo tarpaulin --out Html 176nix develop -c cargo tarpaulin --out Html
133``` 177```
134 178
179**Note:** Always use `nix develop` to ensure the correct build environment. See [docs/how-to/nix-flakes.md](docs/how-to/nix-flakes.md) for details.
180
135## Project Structure 181## Project Structure
136 182
137``` 183```
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
new file mode 100644
index 0000000..88aefed
--- /dev/null
+++ b/src/nostr/events.rs
@@ -0,0 +1,606 @@
1/// NIP-34 Git Repository Event Handling
2///
3/// This module handles Git repository announcements (kind 30617) and
4/// repository state announcements (kind 30618) according to NIP-34 and GRASP-01.
5///
6/// Reference:
7/// - NIP-34: https://nips.nostr.com/34
8/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
9
10use anyhow::{anyhow, Result};
11use nostr_sdk::{Event, Kind, TagKind, ToBech32};
12
13/// NIP-34 Repository Announcement (kind 30617)
14pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
15
16/// NIP-34 Repository State Announcement (kind 30618)
17pub const KIND_REPOSITORY_STATE: u16 = 30618;
18
19/// Repository announcement details extracted from NIP-34 event
20#[derive(Debug, Clone)]
21pub struct RepositoryAnnouncement {
22 pub event: Event,
23 pub identifier: String,
24 pub name: Option<String>,
25 pub description: Option<String>,
26 pub clone_urls: Vec<String>,
27 pub relays: Vec<String>,
28 pub web_urls: Vec<String>,
29 pub maintainers: Vec<String>,
30}
31
32impl RepositoryAnnouncement {
33 /// Parse a repository announcement from a NIP-34 kind 30617 event
34 pub fn from_event(event: Event) -> Result<Self> {
35 if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
36 return Err(anyhow!(
37 "Invalid event kind: expected {}, got {}",
38 KIND_REPOSITORY_ANNOUNCEMENT,
39 event.kind
40 ));
41 }
42
43 // Extract identifier (required)
44 let identifier = event
45 .tags
46 .iter()
47 .find(|t| t.kind() == TagKind::d())
48 .and_then(|t| t.content())
49 .ok_or_else(|| anyhow!("Repository announcement missing 'd' tag (identifier)"))?
50 .to_string();
51
52 // Extract optional name
53 let name = event
54 .tags
55 .iter()
56 .find(|t| matches!(t.kind(), TagKind::Name))
57 .and_then(|t| t.content())
58 .map(|s| s.to_string());
59
60 // Extract description from content
61 let description = if event.content.is_empty() {
62 None
63 } else {
64 Some(event.content.clone())
65 };
66
67 // Extract clone URLs
68 let clone_urls = event
69 .tags
70 .iter()
71 .filter(|t| matches!(t.kind(), TagKind::Clone))
72 .flat_map(|t| {
73 let vec = t.clone().to_vec();
74 // Skip first element (tag name), rest are values
75 vec.into_iter().skip(1)
76 })
77 .collect();
78
79 // Extract relays
80 let relays = event
81 .tags
82 .iter()
83 .filter(|t| matches!(t.kind(), TagKind::Relays))
84 .flat_map(|t| {
85 let vec = t.clone().to_vec();
86 // Skip first element (tag name), rest are values
87 vec.into_iter().skip(1)
88 })
89 .collect();
90
91 // Extract web URLs
92 let web_urls = event
93 .tags
94 .iter()
95 .filter(|t| {
96 if let TagKind::Custom(s) = t.kind() {
97 s.as_ref() == "web"
98 } else {
99 false
100 }
101 })
102 .flat_map(|t| {
103 let vec = t.clone().to_vec();
104 // Skip first element (tag name), rest are values
105 vec.into_iter().skip(1)
106 })
107 .collect();
108
109 // Extract maintainers (other-user tags)
110 let maintainers = event
111 .tags
112 .iter()
113 .filter(|t| t.kind() == TagKind::p())
114 .filter_map(|t| t.content())
115 .map(|s| s.to_string())
116 .collect();
117
118 Ok(RepositoryAnnouncement {
119 event,
120 identifier,
121 name,
122 description,
123 clone_urls,
124 relays,
125 web_urls,
126 maintainers,
127 })
128 }
129
130 /// Check if this announcement lists the given domain in clone URLs
131 pub fn has_clone_url(&self, domain: &str) -> bool {
132 self.clone_urls.iter().any(|url| url.contains(domain))
133 }
134
135 /// Check if this announcement lists the given relay
136 pub fn has_relay(&self, relay: &str) -> bool {
137 self.relays.iter().any(|r| r.contains(relay))
138 }
139
140 /// Check if this announcement lists the service (both clone and relay)
141 ///
142 /// GRASP-01 requirement: MUST reject announcements that do not list
143 /// the service in both `clone` and `relays` tags unless implementing GRASP-05.
144 pub fn lists_service(&self, domain: &str) -> bool {
145 self.has_clone_url(domain) && self.has_relay(domain)
146 }
147
148 /// Get the npub of the repository owner
149 pub fn owner_npub(&self) -> String {
150 self.event.pubkey.to_bech32().unwrap_or_default()
151 }
152
153 /// Get the repository path: <npub>/<identifier>.git
154 pub fn repo_path(&self) -> String {
155 format!("{}/{}.git", self.owner_npub(), self.identifier)
156 }
157}
158
159/// Repository state details extracted from NIP-34 event
160#[derive(Debug, Clone)]
161pub struct RepositoryState {
162 pub event: Event,
163 pub identifier: String,
164 pub branches: Vec<BranchState>,
165 pub tags: Vec<TagState>,
166}
167
168/// Branch state (ref with commit hash)
169#[derive(Debug, Clone)]
170pub struct BranchState {
171 pub name: String,
172 pub commit: String,
173}
174
175/// Tag state (ref with commit hash)
176#[derive(Debug, Clone)]
177pub struct TagState {
178 pub name: String,
179 pub commit: String,
180}
181
182impl RepositoryState {
183 /// Parse a repository state from a NIP-34 kind 30618 event
184 pub fn from_event(event: Event) -> Result<Self> {
185 if event.kind != Kind::from(KIND_REPOSITORY_STATE) {
186 return Err(anyhow!(
187 "Invalid event kind: expected {}, got {}",
188 KIND_REPOSITORY_STATE,
189 event.kind
190 ));
191 }
192
193 // Extract identifier (required)
194 let identifier = event
195 .tags
196 .iter()
197 .find(|t| t.kind() == TagKind::d())
198 .and_then(|t| t.content())
199 .ok_or_else(|| anyhow!("Repository state missing 'd' tag (identifier)"))?
200 .to_string();
201
202 // Extract branches (refs/heads/*)
203 let branches = event
204 .tags
205 .iter()
206 .filter(|t| {
207 if let TagKind::Custom(s) = t.kind() {
208 s.as_ref() == "ref"
209 } else {
210 false
211 }
212 })
213 .filter_map(|t| {
214 let parts = t.clone().to_vec();
215 if parts.len() >= 3 && parts[1].starts_with("refs/heads/") {
216 Some(BranchState {
217 name: parts[1].strip_prefix("refs/heads/").unwrap().to_string(),
218 commit: parts[2].clone(),
219 })
220 } else {
221 None
222 }
223 })
224 .collect();
225
226 // Extract tags (refs/tags/*)
227 let tags = event
228 .tags
229 .iter()
230 .filter(|t| {
231 if let TagKind::Custom(s) = t.kind() {
232 s.as_ref() == "ref"
233 } else {
234 false
235 }
236 })
237 .filter_map(|t| {
238 let parts = t.clone().to_vec();
239 if parts.len() >= 3 && parts[1].starts_with("refs/tags/") {
240 Some(TagState {
241 name: parts[1].strip_prefix("refs/tags/").unwrap().to_string(),
242 commit: parts[2].clone(),
243 })
244 } else {
245 None
246 }
247 })
248 .collect();
249
250 Ok(RepositoryState {
251 event,
252 identifier,
253 branches,
254 tags,
255 })
256 }
257
258 /// Get the commit hash for a branch
259 pub fn get_branch_commit(&self, branch: &str) -> Option<&str> {
260 self.branches
261 .iter()
262 .find(|b| b.name == branch)
263 .map(|b| b.commit.as_str())
264 }
265
266 /// Get the commit hash for a tag
267 pub fn get_tag_commit(&self, tag: &str) -> Option<&str> {
268 self.tags
269 .iter()
270 .find(|t| t.name == tag)
271 .map(|t| t.commit.as_str())
272 }
273
274 /// Get the owner npub
275 pub fn owner_npub(&self) -> String {
276 self.event.pubkey.to_bech32().unwrap_or_default()
277 }
278}
279
280/// Validate a repository announcement according to GRASP-01
281///
282/// Returns Ok(()) if valid, Err with reason if invalid.
283pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
284 // Must be kind 30617
285 if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
286 return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_ANNOUNCEMENT));
287 }
288
289 // Must have identifier
290 let has_identifier = event
291 .tags
292 .iter()
293 .any(|t| t.kind() == TagKind::d());
294 if !has_identifier {
295 return Err(anyhow!("Missing required 'd' tag (identifier)"));
296 }
297
298 // Parse full announcement to validate structure
299 let announcement = RepositoryAnnouncement::from_event(event.clone())?;
300
301 // GRASP-01: MUST reject announcements that do not list the service
302 // in both `clone` and `relays` tags unless implementing GRASP-05
303 if !announcement.lists_service(domain) {
304 return Err(anyhow!(
305 "Announcement must list service in both 'clone' and 'relays' tags. \
306 Found clone URLs: {:?}, relays: {:?}",
307 announcement.clone_urls,
308 announcement.relays
309 ));
310 }
311
312 Ok(())
313}
314
315/// Validate a repository state announcement according to GRASP-01
316///
317/// Returns Ok(()) if valid, Err with reason if invalid.
318pub fn validate_state(event: &Event) -> Result<()> {
319 // Must be kind 30618
320 if event.kind != Kind::from(KIND_REPOSITORY_STATE) {
321 return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_STATE));
322 }
323
324 // Must have identifier
325 let has_identifier = event
326 .tags
327 .iter()
328 .any(|t| t.kind() == TagKind::d());
329 if !has_identifier {
330 return Err(anyhow!("Missing required 'd' tag (identifier)"));
331 }
332
333 // Parse full state to validate structure
334 let _state = RepositoryState::from_event(event.clone())?;
335
336 Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use nostr_sdk::{EventBuilder, Keys, Tag};
343
344 fn create_test_keys() -> Keys {
345 Keys::generate()
346 }
347
348 fn create_announcement_event(
349 keys: &Keys,
350 identifier: &str,
351 clone_urls: Vec<&str>,
352 relays: Vec<&str>,
353 ) -> Event {
354 use nostr_sdk::Tag;
355
356 let mut tags = vec![Tag::custom(
357 nostr_sdk::TagKind::d(),
358 vec![identifier.to_string()],
359 )];
360
361 for url in clone_urls {
362 tags.push(Tag::custom(
363 nostr_sdk::TagKind::Clone,
364 vec![url.to_string()],
365 ));
366 }
367
368 for relay in relays {
369 tags.push(Tag::custom(
370 nostr_sdk::TagKind::Relays,
371 vec![relay.to_string()],
372 ));
373 }
374
375 EventBuilder::new(
376 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
377 "Test repository",
378 )
379 .tags(tags)
380 .sign_with_keys(keys)
381 .unwrap()
382 }
383
384 fn create_state_event(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> Event {
385 use nostr_sdk::Tag;
386
387 let mut tags = vec![Tag::custom(
388 nostr_sdk::TagKind::d(),
389 vec![identifier.to_string()],
390 )];
391
392 for (branch, commit) in branches {
393 tags.push(Tag::custom(
394 nostr_sdk::TagKind::Custom("ref".into()),
395 vec![
396 format!("refs/heads/{}", branch),
397 commit.to_string(),
398 ],
399 ));
400 }
401
402 EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
403 .tags(tags)
404 .sign_with_keys(keys)
405 .unwrap()
406 }
407
408 #[test]
409 fn test_parse_announcement() {
410 let keys = create_test_keys();
411 let event = create_announcement_event(
412 &keys,
413 "test-repo",
414 vec!["https://gitnostr.com/alice/test-repo.git"],
415 vec!["wss://gitnostr.com"],
416 );
417
418 let announcement = RepositoryAnnouncement::from_event(event).unwrap();
419
420 assert_eq!(announcement.identifier, "test-repo");
421 assert_eq!(announcement.clone_urls.len(), 1);
422 assert_eq!(announcement.relays.len(), 1);
423 assert!(announcement.has_clone_url("gitnostr.com"));
424 assert!(announcement.has_relay("gitnostr.com"));
425 assert!(announcement.lists_service("gitnostr.com"));
426 }
427
428 #[test]
429 fn test_parse_announcement_missing_identifier() {
430 let keys = create_test_keys();
431 let event = EventBuilder::new(
432 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
433 "Test repository",
434 )
435 .sign_with_keys(&keys)
436 .unwrap();
437
438 let result = RepositoryAnnouncement::from_event(event);
439 assert!(result.is_err());
440 assert!(result.unwrap_err().to_string().contains("identifier"));
441 }
442
443 #[test]
444 fn test_parse_state() {
445 let keys = create_test_keys();
446 let event = create_state_event(
447 &keys,
448 "test-repo",
449 vec![("main", "a1b2c3d4"), ("develop", "e5f6g7h8")],
450 );
451
452 let state = RepositoryState::from_event(event).unwrap();
453
454 assert_eq!(state.identifier, "test-repo");
455 assert_eq!(state.branches.len(), 2);
456 assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4"));
457 assert_eq!(state.get_branch_commit("develop"), Some("e5f6g7h8"));
458 }
459
460 #[test]
461 fn test_validate_announcement_success() {
462 let keys = create_test_keys();
463 let event = create_announcement_event(
464 &keys,
465 "test-repo",
466 vec!["https://gitnostr.com/alice/test-repo.git"],
467 vec!["wss://gitnostr.com"],
468 );
469
470 let result = validate_announcement(&event, "gitnostr.com");
471 assert!(result.is_ok());
472 }
473
474 #[test]
475 fn test_validate_announcement_missing_clone() {
476 let keys = create_test_keys();
477 let event = create_announcement_event(
478 &keys,
479 "test-repo",
480 vec![], // No clone URLs
481 vec!["wss://gitnostr.com"],
482 );
483
484 let result = validate_announcement(&event, "gitnostr.com");
485 assert!(result.is_err());
486 assert!(result.unwrap_err().to_string().contains("clone"));
487 }
488
489 #[test]
490 fn test_validate_announcement_missing_relay() {
491 let keys = create_test_keys();
492 let event = create_announcement_event(
493 &keys,
494 "test-repo",
495 vec!["https://gitnostr.com/alice/test-repo.git"],
496 vec![], // No relays
497 );
498
499 let result = validate_announcement(&event, "gitnostr.com");
500 assert!(result.is_err());
501 assert!(result.unwrap_err().to_string().contains("relays"));
502 }
503
504 #[test]
505 fn test_validate_announcement_wrong_domain() {
506 let keys = create_test_keys();
507 let event = create_announcement_event(
508 &keys,
509 "test-repo",
510 vec!["https://other-service.com/alice/test-repo.git"],
511 vec!["wss://other-service.com"],
512 );
513
514 let result = validate_announcement(&event, "gitnostr.com");
515 assert!(result.is_err());
516 }
517
518 #[test]
519 fn test_validate_state_success() {
520 let keys = create_test_keys();
521 let event = create_state_event(&keys, "test-repo", vec![("main", "a1b2c3d4")]);
522
523 let result = validate_state(&event);
524 assert!(result.is_ok());
525 }
526
527 #[test]
528 fn test_validate_state_missing_identifier() {
529 let keys = create_test_keys();
530 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
531 .sign_with_keys(&keys)
532 .unwrap();
533
534 let result = validate_state(&event);
535 assert!(result.is_err());
536 assert!(result.unwrap_err().to_string().contains("identifier"));
537 }
538
539 #[test]
540 fn test_announcement_maintainers() {
541 use nostr_sdk::Tag;
542
543 let keys = create_test_keys();
544 let maintainer_keys = create_test_keys();
545
546 let mut tags = vec![
547 Tag::custom(nostr_sdk::TagKind::d(), vec!["test-repo".to_string()]),
548 Tag::custom(
549 nostr_sdk::TagKind::Clone,
550 vec!["https://gitnostr.com/alice/test-repo.git".to_string()],
551 ),
552 Tag::custom(
553 nostr_sdk::TagKind::Relays,
554 vec!["wss://gitnostr.com".to_string()],
555 ),
556 ];
557
558 // Add maintainer
559 tags.push(Tag::public_key(maintainer_keys.public_key()));
560
561 let event = EventBuilder::new(
562 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
563 "Test repository",
564 )
565 .tags(tags)
566 .sign_with_keys(&keys)
567 .unwrap();
568
569 let announcement = RepositoryAnnouncement::from_event(event).unwrap();
570 assert_eq!(announcement.maintainers.len(), 1);
571 }
572
573 #[test]
574 fn test_state_with_tags() {
575 use nostr_sdk::Tag;
576
577 let keys = create_test_keys();
578 let mut tags = vec![Tag::custom(
579 nostr_sdk::TagKind::d(),
580 vec!["test-repo".to_string()],
581 )];
582
583 // Add branch
584 tags.push(Tag::custom(
585 nostr_sdk::TagKind::Custom("ref".into()),
586 vec!["refs/heads/main".to_string(), "a1b2c3d4".to_string()],
587 ));
588
589 // Add tag
590 tags.push(Tag::custom(
591 nostr_sdk::TagKind::Custom("ref".into()),
592 vec!["refs/tags/v1.0.0".to_string(), "e5f6g7h8".to_string()],
593 ));
594
595 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
596 .tags(tags)
597 .sign_with_keys(&keys)
598 .unwrap();
599
600 let state = RepositoryState::from_event(event).unwrap();
601 assert_eq!(state.branches.len(), 1);
602 assert_eq!(state.tags.len(), 1);
603 assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4"));
604 assert_eq!(state.get_tag_commit("v1.0.0"), Some("e5f6g7h8"));
605 }
606}
diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs
index 6193dd9..b485b91 100644
--- a/src/nostr/mod.rs
+++ b/src/nostr/mod.rs
@@ -1 +1,2 @@
1pub mod events;
1pub mod relay; 2pub mod relay;
diff --git a/src/nostr/relay.rs b/src/nostr/relay.rs
index 5af9b04..1033b5b 100644
--- a/src/nostr/relay.rs
+++ b/src/nostr/relay.rs
@@ -1,6 +1,6 @@
1use anyhow::Result; 1use anyhow::Result;
2use futures_util::{SinkExt, StreamExt}; 2use futures_util::{SinkExt, StreamExt};
3use nostr_sdk::{Event, EventId, Filter}; 3use nostr_sdk::{Event, EventId, Filter, Kind};
4use serde_json::{json, Value}; 4use serde_json::{json, Value};
5use std::collections::HashMap; 5use std::collections::HashMap;
6use std::net::SocketAddr; 6use std::net::SocketAddr;
@@ -11,6 +11,7 @@ use tokio_tungstenite::{accept_async, tungstenite::Message};
11use tracing::{debug, error, info, warn}; 11use tracing::{debug, error, info, warn};
12 12
13use crate::config::Config; 13use crate::config::Config;
14use crate::nostr::events::{validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE};
14use crate::storage::Storage; 15use crate::storage::Storage;
15 16
16type Subscriptions = Arc<RwLock<HashMap<String, Vec<Filter>>>>; 17type Subscriptions = Arc<RwLock<HashMap<String, Vec<Filter>>>>;
@@ -140,6 +141,35 @@ async fn handle_event(arr: &[Value], storage: &Storage) -> Result<Vec<Value>> {
140 return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]); 141 return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]);
141 } 142 }
142 143
144 // Validate repository announcements (kind 30617)
145 if event.kind == Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
146 // Get domain from storage config
147 let domain = storage.get_domain();
148
149 match validate_announcement(&event, &domain) {
150 Ok(()) => {
151 info!("✅ Valid repository announcement: {} ({})", event_id, event.kind);
152 }
153 Err(e) => {
154 warn!("❌ Invalid repository announcement: {}", e);
155 return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]);
156 }
157 }
158 }
159
160 // Validate repository state announcements (kind 30618)
161 if event.kind == Kind::from(KIND_REPOSITORY_STATE) {
162 match validate_state(&event) {
163 Ok(()) => {
164 info!("✅ Valid repository state: {} ({})", event_id, event.kind);
165 }
166 Err(e) => {
167 warn!("❌ Invalid repository state: {}", e);
168 return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]);
169 }
170 }
171 }
172
143 // Store the event 173 // Store the event
144 storage.store_event(event.clone()).await?; 174 storage.store_event(event.clone()).await?;
145 175
diff --git a/src/storage/mod.rs b/src/storage/mod.rs
index 2ec6d4e..eab8211 100644
--- a/src/storage/mod.rs
+++ b/src/storage/mod.rs
@@ -12,6 +12,7 @@ use crate::config::Config;
12pub struct Storage { 12pub struct Storage {
13 events: Arc<RwLock<HashMap<String, Event>>>, 13 events: Arc<RwLock<HashMap<String, Event>>>,
14 data_path: String, 14 data_path: String,
15 domain: String,
15} 16}
16 17
17impl Storage { 18impl Storage {
@@ -22,9 +23,14 @@ impl Storage {
22 Ok(Storage { 23 Ok(Storage {
23 events: Arc::new(RwLock::new(HashMap::new())), 24 events: Arc::new(RwLock::new(HashMap::new())),
24 data_path: config.relay_data_path.clone(), 25 data_path: config.relay_data_path.clone(),
26 domain: config.domain.clone(),
25 }) 27 })
26 } 28 }
27 29
30 pub fn get_domain(&self) -> String {
31 self.domain.clone()
32 }
33
28 pub async fn store_event(&self, event: Event) -> Result<()> { 34 pub async fn store_event(&self, event: Event) -> Result<()> {
29 let mut events = self.events.write().await; 35 let mut events = self.events.write().await;
30 events.insert(event.id.to_hex(), event); 36 events.insert(event.id.to_hex(), event);
diff --git a/tests/announcement_tests.rs b/tests/announcement_tests.rs
new file mode 100644
index 0000000..137ba5f
--- /dev/null
+++ b/tests/announcement_tests.rs
@@ -0,0 +1,411 @@
1/// Integration tests for NIP-34 Repository Announcements (GRASP-01)
2///
3/// Tests the acceptance and validation of repository announcements (kind 30617)
4/// and repository state announcements (kind 30618) according to GRASP-01.
5///
6/// Reference: GRASP-01, Lines 9-20
7
8use futures_util::{SinkExt, StreamExt};
9use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
10use serde_json::{json, Value};
11use tokio::net::TcpStream;
12use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
13
14type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
15
16const RELAY_URL: &str = "ws://127.0.0.1:7000";
17const DOMAIN: &str = "127.0.0.1:7000";
18
19const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
20const KIND_REPOSITORY_STATE: u16 = 30618;
21
22/// Helper to connect to the relay
23async fn connect() -> WsStream {
24 let (ws_stream, _) = connect_async(RELAY_URL)
25 .await
26 .expect("Failed to connect to relay");
27 ws_stream
28}
29
30/// Helper to send an event and get the response
31async fn send_event(ws: &mut WsStream, event: nostr_sdk::Event) -> Value {
32 let event_msg = json!(["EVENT", event]);
33 ws.send(Message::Text(event_msg.to_string()))
34 .await
35 .expect("Failed to send event");
36
37 // Read response
38 if let Some(Ok(Message::Text(text))) = ws.next().await {
39 serde_json::from_str(&text).expect("Failed to parse response")
40 } else {
41 panic!("No response received");
42 }
43}
44
45/// Helper to create a repository announcement event
46fn create_announcement(
47 keys: &Keys,
48 identifier: &str,
49 clone_urls: Vec<&str>,
50 relays: Vec<&str>,
51) -> nostr_sdk::Event {
52 let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])];
53
54 for url in clone_urls {
55 tags.push(Tag::custom(
56 TagKind::Custom("clone".into()),
57 vec![url.to_string()],
58 ));
59 }
60
61 for relay in relays {
62 tags.push(Tag::custom(TagKind::Relays, vec![relay.to_string()]));
63 }
64
65 EventBuilder::new(
66 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
67 "Test repository description",
68 tags,
69 )
70 .sign_with_keys(keys)
71 .expect("Failed to sign event")
72}
73
74/// Helper to create a repository state event
75fn create_state(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> nostr_sdk::Event {
76 let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])];
77
78 for (branch, commit) in branches {
79 tags.push(Tag::custom(
80 TagKind::Custom("ref".into()),
81 vec![format!("refs/heads/{}", branch), commit.to_string()],
82 ));
83 }
84
85 EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", tags)
86 .sign_with_keys(keys)
87 .expect("Failed to sign event")
88}
89
90/// GRASP-01, Line 9-10: MUST serve a NIP-01 compliant nostr relay at `/`
91#[tokio::test]
92#[ignore] // Requires relay to be running
93async fn test_relay_accepts_connection() {
94 let _ws = connect().await;
95 // If we get here, connection succeeded
96}
97
98/// GRASP-01, Line 11: MUST accept repository announcements (kind 30617)
99#[tokio::test]
100#[ignore] // Requires relay to be running
101async fn test_accepts_valid_announcement() {
102 let mut ws = connect().await;
103 let keys = Keys::generate();
104
105 let event = create_announcement(
106 &keys,
107 "test-repo",
108 vec![&format!("https://{}/alice/test-repo.git", DOMAIN)],
109 vec![&format!("wss://{}", DOMAIN)],
110 );
111
112 let response = send_event(&mut ws, event.clone()).await;
113
114 // Should be ["OK", event_id, true, ""]
115 assert_eq!(response[0], "OK");
116 assert_eq!(response[1], event.id.to_hex());
117 assert_eq!(response[2], true, "Event should be accepted");
118}
119
120/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
121/// in both `clone` and `relays` tags
122#[tokio::test]
123#[ignore] // Requires relay to be running
124async fn test_rejects_announcement_without_clone() {
125 let mut ws = connect().await;
126 let keys = Keys::generate();
127
128 // Missing clone tag
129 let event = create_announcement(
130 &keys,
131 "test-repo",
132 vec![], // No clone URLs
133 vec![&format!("wss://{}", DOMAIN)],
134 );
135
136 let response = send_event(&mut ws, event.clone()).await;
137
138 // Should be rejected
139 assert_eq!(response[0], "OK");
140 assert_eq!(response[1], event.id.to_hex());
141 assert_eq!(response[2], false, "Event should be rejected");
142
143 let message = response[3].as_str().unwrap();
144 assert!(
145 message.contains("clone") || message.contains("invalid"),
146 "Error message should mention clone requirement: {}",
147 message
148 );
149}
150
151/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
152/// in both `clone` and `relays` tags
153#[tokio::test]
154#[ignore] // Requires relay to be running
155async fn test_rejects_announcement_without_relay() {
156 let mut ws = connect().await;
157 let keys = Keys::generate();
158
159 // Missing relay tag
160 let event = create_announcement(
161 &keys,
162 "test-repo",
163 vec![&format!("https://{}/alice/test-repo.git", DOMAIN)],
164 vec![], // No relays
165 );
166
167 let response = send_event(&mut ws, event.clone()).await;
168
169 // Should be rejected
170 assert_eq!(response[0], "OK");
171 assert_eq!(response[1], event.id.to_hex());
172 assert_eq!(response[2], false, "Event should be rejected");
173
174 let message = response[3].as_str().unwrap();
175 assert!(
176 message.contains("relays") || message.contains("invalid"),
177 "Error message should mention relay requirement: {}",
178 message
179 );
180}
181
182/// GRASP-01, Line 12-13: MUST reject announcements listing other services
183#[tokio::test]
184#[ignore] // Requires relay to be running
185async fn test_rejects_announcement_for_other_service() {
186 let mut ws = connect().await;
187 let keys = Keys::generate();
188
189 // Lists different service
190 let event = create_announcement(
191 &keys,
192 "test-repo",
193 vec!["https://other-service.com/alice/test-repo.git"],
194 vec!["wss://other-service.com"],
195 );
196
197 let response = send_event(&mut ws, event.clone()).await;
198
199 // Should be rejected
200 assert_eq!(response[0], "OK");
201 assert_eq!(response[1], event.id.to_hex());
202 assert_eq!(response[2], false, "Event should be rejected");
203}
204
205/// GRASP-01, Line 11: MUST accept repository state announcements (kind 30618)
206#[tokio::test]
207#[ignore] // Requires relay to be running
208async fn test_accepts_valid_state() {
209 let mut ws = connect().await;
210 let keys = Keys::generate();
211
212 let event = create_state(
213 &keys,
214 "test-repo",
215 vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
216 );
217
218 let response = send_event(&mut ws, event.clone()).await;
219
220 // Should be accepted
221 assert_eq!(response[0], "OK");
222 assert_eq!(response[1], event.id.to_hex());
223 assert_eq!(response[2], true, "State event should be accepted");
224}
225
226/// Test state event with multiple branches
227#[tokio::test]
228#[ignore] // Requires relay to be running
229async fn test_accepts_state_with_multiple_branches() {
230 let mut ws = connect().await;
231 let keys = Keys::generate();
232
233 let event = create_state(
234 &keys,
235 "test-repo",
236 vec![
237 ("main", "a1b2c3d4e5f6789012345678901234567890abcd"),
238 ("develop", "b2c3d4e5f6789012345678901234567890abcde"),
239 ("feature-x", "c3d4e5f6789012345678901234567890abcdef1"),
240 ],
241 );
242
243 let response = send_event(&mut ws, event.clone()).await;
244
245 assert_eq!(response[0], "OK");
246 assert_eq!(response[2], true, "State event should be accepted");
247}
248
249/// Test state event without identifier should be rejected
250#[tokio::test]
251#[ignore] // Requires relay to be running
252async fn test_rejects_state_without_identifier() {
253 let mut ws = connect().await;
254 let keys = Keys::generate();
255
256 // Create state without identifier
257 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", vec![])
258 .sign_with_keys(&keys)
259 .expect("Failed to sign event");
260
261 let response = send_event(&mut ws, event.clone()).await;
262
263 // Should be rejected
264 assert_eq!(response[0], "OK");
265 assert_eq!(response[1], event.id.to_hex());
266 assert_eq!(response[2], false, "Event should be rejected");
267
268 let message = response[3].as_str().unwrap();
269 assert!(
270 message.contains("identifier") || message.contains("invalid"),
271 "Error message should mention identifier requirement: {}",
272 message
273 );
274}
275
276/// Test querying for announcements
277#[tokio::test]
278#[ignore] // Requires relay to be running
279async fn test_query_announcements() {
280 let mut ws = connect().await;
281 let keys = Keys::generate();
282
283 // Send an announcement
284 let event = create_announcement(
285 &keys,
286 "query-test-repo",
287 vec![&format!("https://{}/alice/query-test-repo.git", DOMAIN)],
288 vec![&format!("wss://{}", DOMAIN)],
289 );
290
291 send_event(&mut ws, event.clone()).await;
292
293 // Query for announcements
294 let req = json!([
295 "REQ",
296 "test-sub",
297 {
298 "kinds": [KIND_REPOSITORY_ANNOUNCEMENT],
299 "authors": [keys.public_key().to_hex()]
300 }
301 ]);
302
303 ws.send(Message::Text(req.to_string()))
304 .await
305 .expect("Failed to send REQ");
306
307 // Read responses
308 let mut found_event = false;
309 let mut got_eose = false;
310
311 for _ in 0..10 {
312 if let Some(Ok(Message::Text(text))) = ws.next().await {
313 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
314
315 if response[0] == "EVENT" {
316 assert_eq!(response[1], "test-sub");
317 found_event = true;
318 } else if response[0] == "EOSE" {
319 assert_eq!(response[1], "test-sub");
320 got_eose = true;
321 break;
322 }
323 }
324 }
325
326 assert!(found_event, "Should have received the announcement");
327 assert!(got_eose, "Should have received EOSE");
328}
329
330/// Test querying for state events
331#[tokio::test]
332#[ignore] // Requires relay to be running
333async fn test_query_states() {
334 let mut ws = connect().await;
335 let keys = Keys::generate();
336
337 // Send a state event
338 let event = create_state(
339 &keys,
340 "query-test-repo",
341 vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
342 );
343
344 send_event(&mut ws, event.clone()).await;
345
346 // Query for states
347 let req = json!([
348 "REQ",
349 "test-sub",
350 {
351 "kinds": [KIND_REPOSITORY_STATE],
352 "authors": [keys.public_key().to_hex()]
353 }
354 ]);
355
356 ws.send(Message::Text(req.to_string()))
357 .await
358 .expect("Failed to send REQ");
359
360 // Read responses
361 let mut found_event = false;
362 let mut got_eose = false;
363
364 for _ in 0..10 {
365 if let Some(Ok(Message::Text(text))) = ws.next().await {
366 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
367
368 if response[0] == "EVENT" {
369 assert_eq!(response[1], "test-sub");
370 found_event = true;
371 } else if response[0] == "EOSE" {
372 assert_eq!(response[1], "test-sub");
373 got_eose = true;
374 break;
375 }
376 }
377 }
378
379 assert!(found_event, "Should have received the state event");
380 assert!(got_eose, "Should have received EOSE");
381}
382
383/// Test duplicate event handling
384#[tokio::test]
385#[ignore] // Requires relay to be running
386async fn test_duplicate_announcement() {
387 let mut ws = connect().await;
388 let keys = Keys::generate();
389
390 let event = create_announcement(
391 &keys,
392 "duplicate-test",
393 vec![&format!("https://{}/alice/duplicate-test.git", DOMAIN)],
394 vec![&format!("wss://{}", DOMAIN)],
395 );
396
397 // Send first time
398 let response1 = send_event(&mut ws, event.clone()).await;
399 assert_eq!(response1[2], true, "First send should succeed");
400
401 // Send second time (duplicate)
402 let response2 = send_event(&mut ws, event.clone()).await;
403 assert_eq!(response2[2], true, "Duplicate should be acknowledged");
404
405 let message = response2[3].as_str().unwrap();
406 assert!(
407 message.contains("duplicate") || message.is_empty(),
408 "Should indicate duplicate: {}",
409 message
410 );
411}