add announcement tests

This commit is contained in:
DanConwayDev
2025-11-04 14:33:18 +00:00
parent 4d89f4537c
commit c2c0cdba4a
6 changed files with 1113 additions and 13 deletions
+58 -12
View File
@@ -76,17 +76,22 @@ This approach provides:
git clone https://gitworkshop.dev/ngit-grasp
cd ngit-grasp
# Build
cargo build --release
# Build (using Nix for reproducible environment)
nix develop -c cargo build --release
# Configure
cp .env.example .env
# Edit .env with your settings
# Run
cargo run --release
nix develop -c cargo run --release
# Run tests
nix develop -c cargo test --lib
```
**Don't have Nix?** See [Getting Started Tutorial](docs/tutorials/getting-started.md) for alternative setup methods.
## Configuration
Environment variables (see `.env.example`):
@@ -114,24 +119,65 @@ We use the **[Diátaxis](https://diataxis.fr/)** framework for documentation:
See [Architecture Overview](docs/explanation/architecture.md) for system design and [Test Strategy](docs/reference/test-strategy.md) for testing approach.
### Running Tests
We have two test suites:
**1. Main Project Tests (ngit-grasp)**
```bash
# Run tests
cargo test
# Run unit tests (no external dependencies)
nix develop -c cargo test --lib
# Run compliance tests
cargo test --test compliance
# Run integration tests (tests our relay implementation)
# First, start ngit-grasp relay in one terminal:
NGIT_BIND_ADDRESS=127.0.0.1:7000 nix develop -c cargo run
# Then in another terminal, run integration tests:
nix develop -c cargo test --test announcement_tests --ignored
# Or use the test script (starts relay automatically):
./test_relay.sh
```
**2. GRASP Audit Tool (grasp-audit)**
The audit tool tests GRASP compliance of any relay (including ours or external ones).
```bash
# Enter grasp-audit directory
cd grasp-audit
# Run unit tests
nix develop -c cargo test
# Test against our ngit-grasp relay:
# First, start ngit-grasp (in another terminal):
cd .. && NGIT_BIND_ADDRESS=127.0.0.1:7000 nix develop -c cargo run
# Then run audit:
nix develop -c cargo run -- --url ws://127.0.0.1:7000
# Or test against any external relay:
nix develop -c cargo run -- --url wss://relay.example.com
```
### Development Commands
```bash
# Run with logging
RUST_LOG=debug cargo run
RUST_LOG=debug nix develop -c cargo run
# Check code
cargo clippy
cargo fmt --check
nix develop -c cargo clippy
nix develop -c cargo fmt --check
# Generate test coverage
cargo tarpaulin --out Html
# Generate test coverage (requires tarpaulin)
nix develop -c cargo tarpaulin --out Html
```
**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.
## Project Structure
```
+606
View File
@@ -0,0 +1,606 @@
/// NIP-34 Git Repository Event Handling
///
/// This module handles Git repository announcements (kind 30617) and
/// repository state announcements (kind 30618) according to NIP-34 and GRASP-01.
///
/// Reference:
/// - NIP-34: https://nips.nostr.com/34
/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
use anyhow::{anyhow, Result};
use nostr_sdk::{Event, Kind, TagKind, ToBech32};
/// NIP-34 Repository Announcement (kind 30617)
pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
/// NIP-34 Repository State Announcement (kind 30618)
pub const KIND_REPOSITORY_STATE: u16 = 30618;
/// Repository announcement details extracted from NIP-34 event
#[derive(Debug, Clone)]
pub struct RepositoryAnnouncement {
pub event: Event,
pub identifier: String,
pub name: Option<String>,
pub description: Option<String>,
pub clone_urls: Vec<String>,
pub relays: Vec<String>,
pub web_urls: Vec<String>,
pub maintainers: Vec<String>,
}
impl RepositoryAnnouncement {
/// Parse a repository announcement from a NIP-34 kind 30617 event
pub fn from_event(event: Event) -> Result<Self> {
if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
return Err(anyhow!(
"Invalid event kind: expected {}, got {}",
KIND_REPOSITORY_ANNOUNCEMENT,
event.kind
));
}
// Extract identifier (required)
let identifier = event
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow!("Repository announcement missing 'd' tag (identifier)"))?
.to_string();
// Extract optional name
let name = event
.tags
.iter()
.find(|t| matches!(t.kind(), TagKind::Name))
.and_then(|t| t.content())
.map(|s| s.to_string());
// Extract description from content
let description = if event.content.is_empty() {
None
} else {
Some(event.content.clone())
};
// Extract clone URLs
let clone_urls = event
.tags
.iter()
.filter(|t| matches!(t.kind(), TagKind::Clone))
.flat_map(|t| {
let vec = t.clone().to_vec();
// Skip first element (tag name), rest are values
vec.into_iter().skip(1)
})
.collect();
// Extract relays
let relays = event
.tags
.iter()
.filter(|t| matches!(t.kind(), TagKind::Relays))
.flat_map(|t| {
let vec = t.clone().to_vec();
// Skip first element (tag name), rest are values
vec.into_iter().skip(1)
})
.collect();
// Extract web URLs
let web_urls = event
.tags
.iter()
.filter(|t| {
if let TagKind::Custom(s) = t.kind() {
s.as_ref() == "web"
} else {
false
}
})
.flat_map(|t| {
let vec = t.clone().to_vec();
// Skip first element (tag name), rest are values
vec.into_iter().skip(1)
})
.collect();
// Extract maintainers (other-user tags)
let maintainers = event
.tags
.iter()
.filter(|t| t.kind() == TagKind::p())
.filter_map(|t| t.content())
.map(|s| s.to_string())
.collect();
Ok(RepositoryAnnouncement {
event,
identifier,
name,
description,
clone_urls,
relays,
web_urls,
maintainers,
})
}
/// Check if this announcement lists the given domain in clone URLs
pub fn has_clone_url(&self, domain: &str) -> bool {
self.clone_urls.iter().any(|url| url.contains(domain))
}
/// Check if this announcement lists the given relay
pub fn has_relay(&self, relay: &str) -> bool {
self.relays.iter().any(|r| r.contains(relay))
}
/// Check if this announcement lists the service (both clone and relay)
///
/// GRASP-01 requirement: MUST reject announcements that do not list
/// the service in both `clone` and `relays` tags unless implementing GRASP-05.
pub fn lists_service(&self, domain: &str) -> bool {
self.has_clone_url(domain) && self.has_relay(domain)
}
/// Get the npub of the repository owner
pub fn owner_npub(&self) -> String {
self.event.pubkey.to_bech32().unwrap_or_default()
}
/// Get the repository path: <npub>/<identifier>.git
pub fn repo_path(&self) -> String {
format!("{}/{}.git", self.owner_npub(), self.identifier)
}
}
/// Repository state details extracted from NIP-34 event
#[derive(Debug, Clone)]
pub struct RepositoryState {
pub event: Event,
pub identifier: String,
pub branches: Vec<BranchState>,
pub tags: Vec<TagState>,
}
/// Branch state (ref with commit hash)
#[derive(Debug, Clone)]
pub struct BranchState {
pub name: String,
pub commit: String,
}
/// Tag state (ref with commit hash)
#[derive(Debug, Clone)]
pub struct TagState {
pub name: String,
pub commit: String,
}
impl RepositoryState {
/// Parse a repository state from a NIP-34 kind 30618 event
pub fn from_event(event: Event) -> Result<Self> {
if event.kind != Kind::from(KIND_REPOSITORY_STATE) {
return Err(anyhow!(
"Invalid event kind: expected {}, got {}",
KIND_REPOSITORY_STATE,
event.kind
));
}
// Extract identifier (required)
let identifier = event
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow!("Repository state missing 'd' tag (identifier)"))?
.to_string();
// Extract branches (refs/heads/*)
let branches = event
.tags
.iter()
.filter(|t| {
if let TagKind::Custom(s) = t.kind() {
s.as_ref() == "ref"
} else {
false
}
})
.filter_map(|t| {
let parts = t.clone().to_vec();
if parts.len() >= 3 && parts[1].starts_with("refs/heads/") {
Some(BranchState {
name: parts[1].strip_prefix("refs/heads/").unwrap().to_string(),
commit: parts[2].clone(),
})
} else {
None
}
})
.collect();
// Extract tags (refs/tags/*)
let tags = event
.tags
.iter()
.filter(|t| {
if let TagKind::Custom(s) = t.kind() {
s.as_ref() == "ref"
} else {
false
}
})
.filter_map(|t| {
let parts = t.clone().to_vec();
if parts.len() >= 3 && parts[1].starts_with("refs/tags/") {
Some(TagState {
name: parts[1].strip_prefix("refs/tags/").unwrap().to_string(),
commit: parts[2].clone(),
})
} else {
None
}
})
.collect();
Ok(RepositoryState {
event,
identifier,
branches,
tags,
})
}
/// Get the commit hash for a branch
pub fn get_branch_commit(&self, branch: &str) -> Option<&str> {
self.branches
.iter()
.find(|b| b.name == branch)
.map(|b| b.commit.as_str())
}
/// Get the commit hash for a tag
pub fn get_tag_commit(&self, tag: &str) -> Option<&str> {
self.tags
.iter()
.find(|t| t.name == tag)
.map(|t| t.commit.as_str())
}
/// Get the owner npub
pub fn owner_npub(&self) -> String {
self.event.pubkey.to_bech32().unwrap_or_default()
}
}
/// Validate a repository announcement according to GRASP-01
///
/// Returns Ok(()) if valid, Err with reason if invalid.
pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
// Must be kind 30617
if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_ANNOUNCEMENT));
}
// Must have identifier
let has_identifier = event
.tags
.iter()
.any(|t| t.kind() == TagKind::d());
if !has_identifier {
return Err(anyhow!("Missing required 'd' tag (identifier)"));
}
// Parse full announcement to validate structure
let announcement = RepositoryAnnouncement::from_event(event.clone())?;
// GRASP-01: MUST reject announcements that do not list the service
// in both `clone` and `relays` tags unless implementing GRASP-05
if !announcement.lists_service(domain) {
return Err(anyhow!(
"Announcement must list service in both 'clone' and 'relays' tags. \
Found clone URLs: {:?}, relays: {:?}",
announcement.clone_urls,
announcement.relays
));
}
Ok(())
}
/// Validate a repository state announcement according to GRASP-01
///
/// Returns Ok(()) if valid, Err with reason if invalid.
pub fn validate_state(event: &Event) -> Result<()> {
// Must be kind 30618
if event.kind != Kind::from(KIND_REPOSITORY_STATE) {
return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_STATE));
}
// Must have identifier
let has_identifier = event
.tags
.iter()
.any(|t| t.kind() == TagKind::d());
if !has_identifier {
return Err(anyhow!("Missing required 'd' tag (identifier)"));
}
// Parse full state to validate structure
let _state = RepositoryState::from_event(event.clone())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::{EventBuilder, Keys, Tag};
fn create_test_keys() -> Keys {
Keys::generate()
}
fn create_announcement_event(
keys: &Keys,
identifier: &str,
clone_urls: Vec<&str>,
relays: Vec<&str>,
) -> Event {
use nostr_sdk::Tag;
let mut tags = vec![Tag::custom(
nostr_sdk::TagKind::d(),
vec![identifier.to_string()],
)];
for url in clone_urls {
tags.push(Tag::custom(
nostr_sdk::TagKind::Clone,
vec![url.to_string()],
));
}
for relay in relays {
tags.push(Tag::custom(
nostr_sdk::TagKind::Relays,
vec![relay.to_string()],
));
}
EventBuilder::new(
Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
"Test repository",
)
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
fn create_state_event(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> Event {
use nostr_sdk::Tag;
let mut tags = vec![Tag::custom(
nostr_sdk::TagKind::d(),
vec![identifier.to_string()],
)];
for (branch, commit) in branches {
tags.push(Tag::custom(
nostr_sdk::TagKind::Custom("ref".into()),
vec![
format!("refs/heads/{}", branch),
commit.to_string(),
],
));
}
EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
#[test]
fn test_parse_announcement() {
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
assert_eq!(announcement.identifier, "test-repo");
assert_eq!(announcement.clone_urls.len(), 1);
assert_eq!(announcement.relays.len(), 1);
assert!(announcement.has_clone_url("gitnostr.com"));
assert!(announcement.has_relay("gitnostr.com"));
assert!(announcement.lists_service("gitnostr.com"));
}
#[test]
fn test_parse_announcement_missing_identifier() {
let keys = create_test_keys();
let event = EventBuilder::new(
Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
"Test repository",
)
.sign_with_keys(&keys)
.unwrap();
let result = RepositoryAnnouncement::from_event(event);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("identifier"));
}
#[test]
fn test_parse_state() {
let keys = create_test_keys();
let event = create_state_event(
&keys,
"test-repo",
vec![("main", "a1b2c3d4"), ("develop", "e5f6g7h8")],
);
let state = RepositoryState::from_event(event).unwrap();
assert_eq!(state.identifier, "test-repo");
assert_eq!(state.branches.len(), 2);
assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4"));
assert_eq!(state.get_branch_commit("develop"), Some("e5f6g7h8"));
}
#[test]
fn test_validate_announcement_success() {
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_ok());
}
#[test]
fn test_validate_announcement_missing_clone() {
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
"test-repo",
vec![], // No clone URLs
vec!["wss://gitnostr.com"],
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("clone"));
}
#[test]
fn test_validate_announcement_missing_relay() {
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec![], // No relays
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("relays"));
}
#[test]
fn test_validate_announcement_wrong_domain() {
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://other-service.com/alice/test-repo.git"],
vec!["wss://other-service.com"],
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_err());
}
#[test]
fn test_validate_state_success() {
let keys = create_test_keys();
let event = create_state_event(&keys, "test-repo", vec![("main", "a1b2c3d4")]);
let result = validate_state(&event);
assert!(result.is_ok());
}
#[test]
fn test_validate_state_missing_identifier() {
let keys = create_test_keys();
let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
.sign_with_keys(&keys)
.unwrap();
let result = validate_state(&event);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("identifier"));
}
#[test]
fn test_announcement_maintainers() {
use nostr_sdk::Tag;
let keys = create_test_keys();
let maintainer_keys = create_test_keys();
let mut tags = vec![
Tag::custom(nostr_sdk::TagKind::d(), vec!["test-repo".to_string()]),
Tag::custom(
nostr_sdk::TagKind::Clone,
vec!["https://gitnostr.com/alice/test-repo.git".to_string()],
),
Tag::custom(
nostr_sdk::TagKind::Relays,
vec!["wss://gitnostr.com".to_string()],
),
];
// Add maintainer
tags.push(Tag::public_key(maintainer_keys.public_key()));
let event = EventBuilder::new(
Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
"Test repository",
)
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
assert_eq!(announcement.maintainers.len(), 1);
}
#[test]
fn test_state_with_tags() {
use nostr_sdk::Tag;
let keys = create_test_keys();
let mut tags = vec![Tag::custom(
nostr_sdk::TagKind::d(),
vec!["test-repo".to_string()],
)];
// Add branch
tags.push(Tag::custom(
nostr_sdk::TagKind::Custom("ref".into()),
vec!["refs/heads/main".to_string(), "a1b2c3d4".to_string()],
));
// Add tag
tags.push(Tag::custom(
nostr_sdk::TagKind::Custom("ref".into()),
vec!["refs/tags/v1.0.0".to_string(), "e5f6g7h8".to_string()],
));
let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let state = RepositoryState::from_event(event).unwrap();
assert_eq!(state.branches.len(), 1);
assert_eq!(state.tags.len(), 1);
assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4"));
assert_eq!(state.get_tag_commit("v1.0.0"), Some("e5f6g7h8"));
}
}
+1
View File
@@ -1 +1,2 @@
pub mod events;
pub mod relay;
+31 -1
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use nostr_sdk::{Event, EventId, Filter};
use nostr_sdk::{Event, EventId, Filter, Kind};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -11,6 +11,7 @@ use tokio_tungstenite::{accept_async, tungstenite::Message};
use tracing::{debug, error, info, warn};
use crate::config::Config;
use crate::nostr::events::{validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE};
use crate::storage::Storage;
type Subscriptions = Arc<RwLock<HashMap<String, Vec<Filter>>>>;
@@ -140,6 +141,35 @@ async fn handle_event(arr: &[Value], storage: &Storage) -> Result<Vec<Value>> {
return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]);
}
// Validate repository announcements (kind 30617)
if event.kind == Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
// Get domain from storage config
let domain = storage.get_domain();
match validate_announcement(&event, &domain) {
Ok(()) => {
info!("✅ Valid repository announcement: {} ({})", event_id, event.kind);
}
Err(e) => {
warn!("❌ Invalid repository announcement: {}", e);
return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]);
}
}
}
// Validate repository state announcements (kind 30618)
if event.kind == Kind::from(KIND_REPOSITORY_STATE) {
match validate_state(&event) {
Ok(()) => {
info!("✅ Valid repository state: {} ({})", event_id, event.kind);
}
Err(e) => {
warn!("❌ Invalid repository state: {}", e);
return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]);
}
}
}
// Store the event
storage.store_event(event.clone()).await?;
+6
View File
@@ -12,6 +12,7 @@ use crate::config::Config;
pub struct Storage {
events: Arc<RwLock<HashMap<String, Event>>>,
data_path: String,
domain: String,
}
impl Storage {
@@ -22,9 +23,14 @@ impl Storage {
Ok(Storage {
events: Arc::new(RwLock::new(HashMap::new())),
data_path: config.relay_data_path.clone(),
domain: config.domain.clone(),
})
}
pub fn get_domain(&self) -> String {
self.domain.clone()
}
pub async fn store_event(&self, event: Event) -> Result<()> {
let mut events = self.events.write().await;
events.insert(event.id.to_hex(), event);
+411
View File
@@ -0,0 +1,411 @@
/// Integration tests for NIP-34 Repository Announcements (GRASP-01)
///
/// Tests the acceptance and validation of repository announcements (kind 30617)
/// and repository state announcements (kind 30618) according to GRASP-01.
///
/// Reference: GRASP-01, Lines 9-20
use futures_util::{SinkExt, StreamExt};
use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
use serde_json::{json, Value};
use tokio::net::TcpStream;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
const RELAY_URL: &str = "ws://127.0.0.1:7000";
const DOMAIN: &str = "127.0.0.1:7000";
const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
const KIND_REPOSITORY_STATE: u16 = 30618;
/// Helper to connect to the relay
async fn connect() -> WsStream {
let (ws_stream, _) = connect_async(RELAY_URL)
.await
.expect("Failed to connect to relay");
ws_stream
}
/// Helper to send an event and get the response
async fn send_event(ws: &mut WsStream, event: nostr_sdk::Event) -> Value {
let event_msg = json!(["EVENT", event]);
ws.send(Message::Text(event_msg.to_string()))
.await
.expect("Failed to send event");
// Read response
if let Some(Ok(Message::Text(text))) = ws.next().await {
serde_json::from_str(&text).expect("Failed to parse response")
} else {
panic!("No response received");
}
}
/// Helper to create a repository announcement event
fn create_announcement(
keys: &Keys,
identifier: &str,
clone_urls: Vec<&str>,
relays: Vec<&str>,
) -> nostr_sdk::Event {
let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])];
for url in clone_urls {
tags.push(Tag::custom(
TagKind::Custom("clone".into()),
vec![url.to_string()],
));
}
for relay in relays {
tags.push(Tag::custom(TagKind::Relays, vec![relay.to_string()]));
}
EventBuilder::new(
Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
"Test repository description",
tags,
)
.sign_with_keys(keys)
.expect("Failed to sign event")
}
/// Helper to create a repository state event
fn create_state(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> nostr_sdk::Event {
let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])];
for (branch, commit) in branches {
tags.push(Tag::custom(
TagKind::Custom("ref".into()),
vec![format!("refs/heads/{}", branch), commit.to_string()],
));
}
EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", tags)
.sign_with_keys(keys)
.expect("Failed to sign event")
}
/// GRASP-01, Line 9-10: MUST serve a NIP-01 compliant nostr relay at `/`
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_relay_accepts_connection() {
let _ws = connect().await;
// If we get here, connection succeeded
}
/// GRASP-01, Line 11: MUST accept repository announcements (kind 30617)
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_accepts_valid_announcement() {
let mut ws = connect().await;
let keys = Keys::generate();
let event = create_announcement(
&keys,
"test-repo",
vec![&format!("https://{}/alice/test-repo.git", DOMAIN)],
vec![&format!("wss://{}", DOMAIN)],
);
let response = send_event(&mut ws, event.clone()).await;
// Should be ["OK", event_id, true, ""]
assert_eq!(response[0], "OK");
assert_eq!(response[1], event.id.to_hex());
assert_eq!(response[2], true, "Event should be accepted");
}
/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
/// in both `clone` and `relays` tags
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_rejects_announcement_without_clone() {
let mut ws = connect().await;
let keys = Keys::generate();
// Missing clone tag
let event = create_announcement(
&keys,
"test-repo",
vec![], // No clone URLs
vec![&format!("wss://{}", DOMAIN)],
);
let response = send_event(&mut ws, event.clone()).await;
// Should be rejected
assert_eq!(response[0], "OK");
assert_eq!(response[1], event.id.to_hex());
assert_eq!(response[2], false, "Event should be rejected");
let message = response[3].as_str().unwrap();
assert!(
message.contains("clone") || message.contains("invalid"),
"Error message should mention clone requirement: {}",
message
);
}
/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
/// in both `clone` and `relays` tags
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_rejects_announcement_without_relay() {
let mut ws = connect().await;
let keys = Keys::generate();
// Missing relay tag
let event = create_announcement(
&keys,
"test-repo",
vec![&format!("https://{}/alice/test-repo.git", DOMAIN)],
vec![], // No relays
);
let response = send_event(&mut ws, event.clone()).await;
// Should be rejected
assert_eq!(response[0], "OK");
assert_eq!(response[1], event.id.to_hex());
assert_eq!(response[2], false, "Event should be rejected");
let message = response[3].as_str().unwrap();
assert!(
message.contains("relays") || message.contains("invalid"),
"Error message should mention relay requirement: {}",
message
);
}
/// GRASP-01, Line 12-13: MUST reject announcements listing other services
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_rejects_announcement_for_other_service() {
let mut ws = connect().await;
let keys = Keys::generate();
// Lists different service
let event = create_announcement(
&keys,
"test-repo",
vec!["https://other-service.com/alice/test-repo.git"],
vec!["wss://other-service.com"],
);
let response = send_event(&mut ws, event.clone()).await;
// Should be rejected
assert_eq!(response[0], "OK");
assert_eq!(response[1], event.id.to_hex());
assert_eq!(response[2], false, "Event should be rejected");
}
/// GRASP-01, Line 11: MUST accept repository state announcements (kind 30618)
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_accepts_valid_state() {
let mut ws = connect().await;
let keys = Keys::generate();
let event = create_state(
&keys,
"test-repo",
vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
);
let response = send_event(&mut ws, event.clone()).await;
// Should be accepted
assert_eq!(response[0], "OK");
assert_eq!(response[1], event.id.to_hex());
assert_eq!(response[2], true, "State event should be accepted");
}
/// Test state event with multiple branches
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_accepts_state_with_multiple_branches() {
let mut ws = connect().await;
let keys = Keys::generate();
let event = create_state(
&keys,
"test-repo",
vec![
("main", "a1b2c3d4e5f6789012345678901234567890abcd"),
("develop", "b2c3d4e5f6789012345678901234567890abcde"),
("feature-x", "c3d4e5f6789012345678901234567890abcdef1"),
],
);
let response = send_event(&mut ws, event.clone()).await;
assert_eq!(response[0], "OK");
assert_eq!(response[2], true, "State event should be accepted");
}
/// Test state event without identifier should be rejected
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_rejects_state_without_identifier() {
let mut ws = connect().await;
let keys = Keys::generate();
// Create state without identifier
let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", vec![])
.sign_with_keys(&keys)
.expect("Failed to sign event");
let response = send_event(&mut ws, event.clone()).await;
// Should be rejected
assert_eq!(response[0], "OK");
assert_eq!(response[1], event.id.to_hex());
assert_eq!(response[2], false, "Event should be rejected");
let message = response[3].as_str().unwrap();
assert!(
message.contains("identifier") || message.contains("invalid"),
"Error message should mention identifier requirement: {}",
message
);
}
/// Test querying for announcements
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_query_announcements() {
let mut ws = connect().await;
let keys = Keys::generate();
// Send an announcement
let event = create_announcement(
&keys,
"query-test-repo",
vec![&format!("https://{}/alice/query-test-repo.git", DOMAIN)],
vec![&format!("wss://{}", DOMAIN)],
);
send_event(&mut ws, event.clone()).await;
// Query for announcements
let req = json!([
"REQ",
"test-sub",
{
"kinds": [KIND_REPOSITORY_ANNOUNCEMENT],
"authors": [keys.public_key().to_hex()]
}
]);
ws.send(Message::Text(req.to_string()))
.await
.expect("Failed to send REQ");
// Read responses
let mut found_event = false;
let mut got_eose = false;
for _ in 0..10 {
if let Some(Ok(Message::Text(text))) = ws.next().await {
let response: Value = serde_json::from_str(&text).expect("Failed to parse");
if response[0] == "EVENT" {
assert_eq!(response[1], "test-sub");
found_event = true;
} else if response[0] == "EOSE" {
assert_eq!(response[1], "test-sub");
got_eose = true;
break;
}
}
}
assert!(found_event, "Should have received the announcement");
assert!(got_eose, "Should have received EOSE");
}
/// Test querying for state events
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_query_states() {
let mut ws = connect().await;
let keys = Keys::generate();
// Send a state event
let event = create_state(
&keys,
"query-test-repo",
vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
);
send_event(&mut ws, event.clone()).await;
// Query for states
let req = json!([
"REQ",
"test-sub",
{
"kinds": [KIND_REPOSITORY_STATE],
"authors": [keys.public_key().to_hex()]
}
]);
ws.send(Message::Text(req.to_string()))
.await
.expect("Failed to send REQ");
// Read responses
let mut found_event = false;
let mut got_eose = false;
for _ in 0..10 {
if let Some(Ok(Message::Text(text))) = ws.next().await {
let response: Value = serde_json::from_str(&text).expect("Failed to parse");
if response[0] == "EVENT" {
assert_eq!(response[1], "test-sub");
found_event = true;
} else if response[0] == "EOSE" {
assert_eq!(response[1], "test-sub");
got_eose = true;
break;
}
}
}
assert!(found_event, "Should have received the state event");
assert!(got_eose, "Should have received EOSE");
}
/// Test duplicate event handling
#[tokio::test]
#[ignore] // Requires relay to be running
async fn test_duplicate_announcement() {
let mut ws = connect().await;
let keys = Keys::generate();
let event = create_announcement(
&keys,
"duplicate-test",
vec![&format!("https://{}/alice/duplicate-test.git", DOMAIN)],
vec![&format!("wss://{}", DOMAIN)],
);
// Send first time
let response1 = send_event(&mut ws, event.clone()).await;
assert_eq!(response1[2], true, "First send should succeed");
// Send second time (duplicate)
let response2 = send_event(&mut ws, event.clone()).await;
assert_eq!(response2[2], true, "Duplicate should be acknowledged");
let message = response2[3].as_str().unwrap();
assert!(
message.contains("duplicate") || message.is_empty(),
"Should indicate duplicate: {}",
message
);
}