mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
add announcement tests
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
pub mod events;
|
||||
pub mod relay;
|
||||
|
||||
+31
-1
@@ -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?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user