fix some clippy fmt warnings

This commit is contained in:
DanConwayDev
2025-11-19 17:01:36 +00:00
parent fa065ad128
commit bf7f4d5381
19 changed files with 918 additions and 699 deletions
+9 -9
View File
@@ -8,12 +8,12 @@ use grasp_audit::*;
async fn main() -> Result<()> {
// Create audit config for CI testing
let config = AuditConfig::ci();
println!("GRASP Audit Example");
println!("==================");
println!("Audit Run ID: {}", config.run_id);
println!();
// Connect to relay
println!("Connecting to relay at ws://localhost:7000...");
let client = match AuditClient::new("ws://localhost:7000", config).await {
@@ -26,28 +26,28 @@ async fn main() -> Result<()> {
return Err(e);
}
};
if !client.is_connected().await {
eprintln!("Not connected to relay");
return Err(anyhow!("Connection failed"));
}
println!("✓ Connected");
println!();
// Run NIP-01 smoke tests
println!("Running NIP-01 smoke tests...");
println!();
let results = specs::Nip01SmokeTests::run_all(&client).await;
// Print results
results.print_report();
// Exit with error if tests failed
if !results.all_passed() {
std::process::exit(1);
}
Ok(())
}
+44 -40
View File
@@ -7,13 +7,13 @@ use nostr_sdk::prelude::*;
pub struct AuditConfig {
/// Unique ID for this audit run
pub run_id: String,
/// Mode: CI (isolated) or Production (live)
pub mode: AuditMode,
/// Cleanup timestamp (events can be cleaned after this)
pub cleanup_after: Timestamp,
/// Whether to actually create events or just query
pub read_only: bool,
}
@@ -23,7 +23,7 @@ pub struct AuditConfig {
pub enum AuditMode {
/// Isolated CI/CD tests - only see own events
CI,
/// Production audit - see all events, minimal writes
Production,
}
@@ -39,7 +39,7 @@ impl AuditConfig {
read_only: false,
}
}
/// Create config for production audit
pub fn production() -> Self {
let run_id = format!("prod-audit-{}", Timestamp::now().as_u64());
@@ -47,10 +47,10 @@ impl AuditConfig {
run_id,
mode: AuditMode::Production,
cleanup_after: Timestamp::now() + 300, // 5 minutes from now
read_only: true, // Default to read-only for production
read_only: true, // Default to read-only for production
}
}
/// Create config with custom run ID
pub fn with_run_id(run_id: String, mode: AuditMode) -> Self {
Self {
@@ -60,7 +60,7 @@ impl AuditConfig {
read_only: mode == AuditMode::Production,
}
}
/// Get audit tags that are automatically added to all events
///
/// These tags are automatically added to all events created via [`AuditEventBuilder`].
@@ -102,22 +102,22 @@ impl AuditConfig {
/// ```
pub fn audit_tags(&self) -> Vec<Tag> {
use nostr_sdk::prelude::{Alphabet, SingleLetterTag};
// Use "t" tags for categorization (standard NIP-01 hashtag type)
let t_tag = SingleLetterTag::lowercase(Alphabet::T);
vec![
Tag::custom(TagKind::SingleLetter(t_tag), vec!["grasp-audit-test-event"]),
Tag::custom(
TagKind::SingleLetter(t_tag),
vec!["grasp-audit-test-event"]
vec![format!("audit-{}", self.run_id)],
),
Tag::custom(
TagKind::SingleLetter(t_tag),
vec![format!("audit-{}", self.run_id)]
),
Tag::custom(
TagKind::SingleLetter(t_tag),
vec![format!("audit-cleanup-after-{}", self.cleanup_after.as_u64())]
vec![format!(
"audit-cleanup-after-{}",
self.cleanup_after.as_u64()
)],
),
]
}
@@ -141,28 +141,28 @@ impl AuditEventBuilder {
config,
}
}
/// Add a tag
pub fn tag(mut self, tag: Tag) -> Self {
self.tags.push(tag);
self
}
/// Add multiple tags
pub fn tags(mut self, tags: Vec<Tag>) -> Self {
self.tags.extend(tags);
self
}
/// Build the event with audit tags
pub fn build(self, keys: &Keys) -> anyhow::Result<Event> {
let mut all_tags = self.tags;
all_tags.extend(self.config.audit_tags());
let event = EventBuilder::new(self.kind, self.content)
.tags(all_tags)
.sign_with_keys(keys)?;
Ok(event)
}
}
@@ -170,7 +170,7 @@ impl AuditEventBuilder {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ci_config() {
let config = AuditConfig::ci();
@@ -178,7 +178,7 @@ mod tests {
assert!(!config.read_only);
assert!(config.run_id.starts_with("ci-"));
}
#[test]
fn test_production_config() {
let config = AuditConfig::production();
@@ -186,18 +186,18 @@ mod tests {
assert!(config.read_only);
assert!(config.run_id.starts_with("prod-audit-"));
}
#[test]
fn test_audit_tags() {
use nostr_sdk::prelude::{Alphabet, SingleLetterTag};
let config = AuditConfig::ci();
let tags = config.audit_tags();
assert_eq!(tags.len(), 3);
let t_tag = SingleLetterTag::lowercase(Alphabet::T);
// All tags should be "t" tags (hashtags)
for tag in &tags {
if let TagKind::SingleLetter(letter) = tag.kind() {
@@ -206,36 +206,40 @@ mod tests {
panic!("Expected SingleLetter tag");
}
}
// Check for "t" tag with "grasp-audit-test-event"
assert!(tags.iter().any(|t| {
t.content() == Some("grasp-audit-test-event")
}));
assert!(tags
.iter()
.any(|t| { t.content() == Some("grasp-audit-test-event") }));
// Check for "t" tag with "audit-{run_id}"
assert!(tags.iter().any(|t| {
t.content().map(|c| c.starts_with("audit-ci-")).unwrap_or(false)
t.content()
.map(|c| c.starts_with("audit-ci-"))
.unwrap_or(false)
}));
// Check for "t" tag with "audit-cleanup-after-{timestamp}"
assert!(tags.iter().any(|t| {
t.content().map(|c| c.starts_with("audit-cleanup-after-")).unwrap_or(false)
t.content()
.map(|c| c.starts_with("audit-cleanup-after-"))
.unwrap_or(false)
}));
}
#[test]
fn test_audit_event_builder() {
let config = AuditConfig::ci();
let keys = Keys::generate();
let event = AuditEventBuilder::new(Kind::TextNote, "test", config.clone())
.tag(Tag::custom(TagKind::Custom("test".into()), vec!["value"]))
.build(&keys)
.unwrap();
// Should have our custom tag + 3 audit tags
assert!(event.tags.len() >= 4);
// Verify event is valid
assert!(event.verify().is_ok());
}
+21 -15
View File
@@ -18,11 +18,11 @@ enum Commands {
/// Relay URL (e.g., ws://localhost:7000)
#[arg(short, long)]
relay: String,
/// Mode: ci or production
#[arg(short, long, default_value = "ci")]
mode: String,
/// Spec to test (nip01-smoke, all)
#[arg(short, long, default_value = "nip01-smoke")]
spec: String,
@@ -35,12 +35,12 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into())
.add_directive(tracing::Level::INFO.into()),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Audit { relay, mode, spec } => {
let config = match mode.as_str() {
@@ -48,7 +48,7 @@ async fn main() -> Result<()> {
"production" => AuditConfig::production(),
_ => return Err(anyhow!("Invalid mode: {}. Use 'ci' or 'production'", mode)),
};
println!("🔍 GRASP Audit Tool");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("Relay: {}", relay);
@@ -57,17 +57,18 @@ async fn main() -> Result<()> {
println!("Run ID: {}", config.run_id);
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
println!("Connecting to relay...");
let client = AuditClient::new(&relay, config).await
let client = AuditClient::new(&relay, config)
.await
.map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
if !client.is_connected().await {
return Err(anyhow!("Could not establish connection to relay"));
}
println!("✓ Connected\n");
let results = match spec.as_str() {
"nip01-smoke" => {
println!("Running NIP-01 smoke tests...\n");
@@ -77,11 +78,16 @@ async fn main() -> Result<()> {
println!("Running all tests...\n");
specs::Nip01SmokeTests::run_all(&client).await
}
_ => return Err(anyhow!("Unknown spec: {}. Use 'nip01-smoke' or 'all'", spec)),
_ => {
return Err(anyhow!(
"Unknown spec: {}. Use 'nip01-smoke' or 'all'",
spec
))
}
};
results.print_report();
if !results.all_passed() {
println!("❌ Some tests failed");
std::process::exit(1);
@@ -90,6 +96,6 @@ async fn main() -> Result<()> {
}
}
}
Ok(())
}
+104 -70
View File
@@ -24,32 +24,32 @@ impl AuditClient {
keys,
}
}
/// Create a new audit client
pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> {
let keys = Keys::generate();
let client = Client::new(keys.clone());
// Add relay and connect
client.add_relay(relay_url).await?;
client.connect().await;
// Wait for connection to establish (with retries)
let mut attempts = 0;
let mut connected = false;
while attempts < 20 {
tokio::time::sleep(Duration::from_millis(100)).await;
let relays = client.relays().await;
connected = relays.values().any(|r| r.is_connected());
if connected {
break;
}
attempts += 1;
}
// Verify we actually connected
if !connected {
return Err(anyhow!(
@@ -68,22 +68,22 @@ impl AuditClient {
relay_url
));
}
// Give it a bit more time to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(Self {
client,
config,
keys,
})
}
/// Get the public key for this audit client
pub fn public_key(&self) -> PublicKey {
self.keys.public_key()
}
/// Check if connected to relay
pub async fn is_connected(&self) -> bool {
// Check if we have any connected relays
@@ -95,29 +95,29 @@ impl AuditClient {
}
false
}
/// Send an event (with audit tags automatically added)
pub async fn send_event(&self, event: Event) -> Result<EventId> {
if self.config.read_only {
return Err(anyhow!("Client is in read-only mode"));
}
let output = self.client.send_event(&event).await?;
let event_id = *output.id();
// Check if any relay rejected the event and return the error message
if !output.failed.is_empty() {
// Get the first failed relay error message
let (relay_url, error) = output.failed.iter().next().unwrap();
return Err(anyhow!("Relay {} rejected event: {}", relay_url, error));
}
// Wait a bit for event to propagate
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(event_id)
}
/// Create an event builder that automatically includes audit tags
///
/// All events built through this method will automatically have audit tags appended
@@ -153,11 +153,11 @@ impl AuditClient {
pub fn event_builder(&self, kind: Kind, content: impl Into<String>) -> AuditEventBuilder {
AuditEventBuilder::new(kind, content, self.config.clone())
}
/// Query events, optionally filtered to this audit run
pub async fn query(&self, mut filter: Filter) -> Result<Vec<Event>> {
use nostr_sdk::prelude::{Alphabet, SingleLetterTag};
if self.config.mode == AuditMode::CI {
// In CI mode, only see our own audit events
// Filter by "t" tags (hashtags)
@@ -167,14 +167,15 @@ impl AuditClient {
.custom_tag(t_tag, format!("audit-{}", self.config.run_id));
}
// In Production mode, see all events (no filter modification)
let events = self.client
let events = self
.client
.fetch_events(filter, Duration::from_secs(5))
.await?;
Ok(events.into_iter().collect())
}
/// Subscribe to events with a callback
pub async fn subscribe(
&self,
@@ -183,27 +184,25 @@ impl AuditClient {
) -> Result<Vec<Event>> {
let timeout = timeout.unwrap_or(Duration::from_secs(5));
let mut all_events = Vec::new();
for filter in filters {
let events = self.client
.fetch_events(filter, timeout)
.await?;
let events = self.client.fetch_events(filter, timeout).await?;
all_events.extend(events.into_iter());
}
Ok(all_events)
}
/// Get the underlying nostr client (for advanced usage)
pub fn client(&self) -> &Client {
&self.client
}
/// Get the keys (for signing custom events)
pub fn keys(&self) -> &Keys {
&self.keys
}
/// Create a NIP-34 repository announcement event
///
/// This helper creates a properly formatted NIP-34 announcement that will be
@@ -216,37 +215,58 @@ impl AuditClient {
/// A built and signed Event ready to be sent to the relay
pub async fn create_repo_announcement(&self, test_name: &str) -> Result<Event> {
// Get relay URL from client
let relay_url = self.client.relays().await
let relay_url = self
.client
.relays()
.await
.keys()
.next()
.ok_or_else(|| anyhow!("No relay connected"))?
.to_string();
// Convert WebSocket URL to HTTP URL for clone tag
let http_url = relay_url
.replace("ws://", "http://")
.replace("wss://", "https://");
// Create unique repository identifier using UUID for consistency
let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]);
// Get npub for clone URL
let npub = self.public_key().to_bech32()
let npub = self
.public_key()
.to_bech32()
.map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?;
// Build kind 30617 repository announcement
let event = self.event_builder(Kind::GitRepoAnnouncement, format!("Test repository for {}", test_name))
let event = self
.event_builder(
Kind::GitRepoAnnouncement,
format!("Test repository for {}", test_name),
)
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(TagKind::custom("name"), vec![format!("{} Test Repository", test_name)]))
.tag(Tag::custom(TagKind::custom("description"), vec![format!("Repository for {} testing", test_name)]))
.tag(Tag::custom(TagKind::custom("clone"), vec![format!("{}/{}/{}.git", http_url, npub, repo_id)]))
.tag(Tag::custom(TagKind::custom("relays"), vec![relay_url.clone()]))
.tag(Tag::custom(
TagKind::custom("name"),
vec![format!("{} Test Repository", test_name)],
))
.tag(Tag::custom(
TagKind::custom("description"),
vec![format!("Repository for {} testing", test_name)],
))
.tag(Tag::custom(
TagKind::custom("clone"),
vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
))
.tag(Tag::custom(
TagKind::custom("relays"),
vec![relay_url.clone()],
))
.build(self.keys())
.map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?;
Ok(event)
}
/// Create an issue (kind 1621) that references a repository
///
/// # Arguments
@@ -265,29 +285,31 @@ impl AuditClient {
additional_tags: Vec<Tag>,
) -> Result<Event> {
// Extract repo_id from the d tag
let repo_id = repo_event.tags.iter()
let repo_id = repo_event
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow!("Repository event must have a 'd' tag"))?
.to_string();
let repo_pubkey = repo_event.pubkey;
let a_tag_value = format!("30617:{}:{}", repo_pubkey, repo_id);
let mut tags = vec![
Tag::custom(TagKind::custom("a"), vec![a_tag_value]),
Tag::custom(TagKind::custom("subject"), vec![issue_title]),
];
// Add any additional tags
tags.extend(additional_tags);
self.event_builder(Kind::Custom(1621), content)
.tags(tags)
.build(self.keys())
.map_err(|e| anyhow!("Failed to build issue event: {}", e))
}
/// Create a NIP-22 comment (kind 1111) for an event
///
/// # Arguments
@@ -306,17 +328,20 @@ impl AuditClient {
let event_kind = event.kind;
let event_pubkey = event.pubkey;
let event_id = event.id;
let mut tags = vec![
Tag::custom(TagKind::custom("E"), vec![event_id.to_hex(), "".to_string(), "root".to_string()]),
Tag::custom(
TagKind::custom("E"),
vec![event_id.to_hex(), "".to_string(), "root".to_string()],
),
Tag::event(event_id),
Tag::custom(TagKind::custom("K"), vec![event_kind.as_u16().to_string()]),
Tag::public_key(event_pubkey),
];
// Add any additional tags
tags.extend(additional_tags);
self.event_builder(Kind::Custom(1111), content)
.tags(tags)
.build(self.keys())
@@ -327,22 +352,22 @@ impl AuditClient {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_client_creation() {
let config = AuditConfig::ci();
// This will fail if no relay is running, which is expected in tests
// In real usage, there should be a relay at the URL
let result = AuditClient::new("ws://localhost:7000", config).await;
// We can't test connection without a running relay
// But we can test that the client is created
if let Ok(client) = result {
assert_eq!(client.config.mode, AuditMode::CI);
}
}
#[test]
fn test_event_builder() {
let config = AuditConfig::ci();
@@ -352,13 +377,13 @@ mod tests {
config: config.clone(),
keys: keys.clone(),
};
let _builder = client.event_builder(Kind::TextNote, "test content");
// Builder should be created successfully
// (We can't test the internal config field as it's private, which is correct)
}
#[test]
fn test_audit_tags_automatically_added() {
let config = AuditConfig::ci();
@@ -368,21 +393,28 @@ mod tests {
config: config.clone(),
keys: keys.clone(),
};
// Create an event with a custom tag
let event = client.event_builder(Kind::TextNote, "test content")
let event = client
.event_builder(Kind::TextNote, "test content")
.tag(Tag::custom(TagKind::custom("custom"), vec!["value"]))
.build(&keys)
.unwrap();
// Should have custom tag (1) + 3 audit tags = at least 4 tags
assert!(event.tags.len() >= 4, "Expected at least 4 tags, got {}", event.tags.len());
assert!(
event.tags.len() >= 4,
"Expected at least 4 tags, got {}",
event.tags.len()
);
// Verify audit tags are present by checking tag content
let tag_contents: Vec<String> = event.tags.iter()
let tag_contents: Vec<String> = event
.tags
.iter()
.filter_map(|t| t.content().map(|s| s.to_string()))
.collect();
// Check for the three required audit tags
assert!(
tag_contents.contains(&"grasp-audit-test-event".to_string()),
@@ -393,10 +425,12 @@ mod tests {
"Missing 'audit-ci-*' tag"
);
assert!(
tag_contents.iter().any(|t| t.starts_with("audit-cleanup-after-")),
tag_contents
.iter()
.any(|t| t.starts_with("audit-cleanup-after-")),
"Missing 'audit-cleanup-after-*' tag"
);
// Verify the custom tag is also present
assert!(
tag_contents.contains(&"value".to_string()),
+95 -67
View File
@@ -33,13 +33,13 @@ use std::sync::{Arc, Mutex};
pub enum FixtureKind {
/// Basic repository announcement (kind 30617)
ValidRepo,
/// Repository with one issue (kind 1621)
RepoWithIssue,
/// Repository with issue and comment (kind 1111)
RepoWithComment,
/// Repository state announcement (kind 30618)
RepoState,
}
@@ -49,7 +49,7 @@ pub enum FixtureKind {
pub enum ContextMode {
/// Create fresh fixtures for each request (test isolation)
Isolated,
/// Reuse shared fixtures across requests (minimal events)
Shared,
}
@@ -104,7 +104,7 @@ impl<'a> TestContext<'a> {
cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Create a test context with explicit mode override
///
/// This is useful for testing the context itself or for advanced use cases
@@ -116,7 +116,7 @@ impl<'a> TestContext<'a> {
cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Get a fixture, creating it if needed based on mode
///
/// # Behavior
@@ -139,7 +139,7 @@ impl<'a> TestContext<'a> {
ContextMode::Shared => self.get_or_create_shared(kind).await,
}
}
/// Get the underlying client for direct access
///
/// This allows tests to use the client directly when needed while still
@@ -147,23 +147,27 @@ impl<'a> TestContext<'a> {
pub fn client(&self) -> &'a AuditClient {
self.client
}
/// Get the current context mode
pub fn mode(&self) -> ContextMode {
self.mode
}
/// Create a fresh fixture (always creates new)
async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> {
let event = self.build_fixture(kind).await
let event = self
.build_fixture(kind)
.await
.with_context(|| format!("Failed to build {:?} fixture", kind))?;
self.client.send_event(event.clone()).await
self.client
.send_event(event.clone())
.await
.with_context(|| format!("Failed to send {:?} fixture event to relay", kind))?;
Ok(event)
}
/// Get or create a shared fixture (caches for reuse)
async fn get_or_create_shared(&self, kind: FixtureKind) -> Result<Event> {
// Check cache first
@@ -173,39 +177,54 @@ impl<'a> TestContext<'a> {
return Ok(event.clone());
}
}
// Not in cache, create it
let event = self.build_fixture(kind).await
let event = self
.build_fixture(kind)
.await
.with_context(|| format!("Failed to build {:?} fixture for shared cache", kind))?;
self.client.send_event(event.clone()).await
.with_context(|| format!("Failed to send {:?} fixture event to relay (shared cache)", kind))?;
self.client
.send_event(event.clone())
.await
.with_context(|| {
format!(
"Failed to send {:?} fixture event to relay (shared cache)",
kind
)
})?;
// Store in cache
{
let mut cache = self.cache.lock().unwrap();
cache.insert(kind, event.clone());
}
Ok(event)
}
/// Build a fixture event (doesn't send it)
async fn build_fixture(&self, kind: FixtureKind) -> Result<Event> {
match kind {
FixtureKind::ValidRepo => {
let test_name = format!("fixture-{:?}-{}", kind, &uuid::Uuid::new_v4().to_string()[..8]);
let test_name = format!(
"fixture-{:?}-{}",
kind,
&uuid::Uuid::new_v4().to_string()[..8]
);
self.client.create_repo_announcement(&test_name).await
}
FixtureKind::RepoWithIssue => {
use nostr_sdk::prelude::*;
// First create and send repo
let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]);
let test_name = format!(
"fixture-{:?}-{}",
FixtureKind::ValidRepo,
&uuid::Uuid::new_v4().to_string()[..8]
);
let repo = self.client.create_repo_announcement(&test_name).await?;
self.client.send_event(repo.clone()).await?;
// Then create issue referencing it - this will have 'a' tag to repo
// Note: We build the issue but DON'T send it here - the caller will send it
let issue = self.client.create_issue(
@@ -214,64 +233,70 @@ impl<'a> TestContext<'a> {
"Issue content for testing",
vec![],
)?;
// Return the issue - tests can extract repo reference from its 'a' tag
// The caller (create_fresh/get_or_create_shared) will send this event
Ok(issue)
}
FixtureKind::RepoWithComment => {
// First create repo with issue
let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]);
let test_name = format!(
"fixture-{:?}-{}",
FixtureKind::ValidRepo,
&uuid::Uuid::new_v4().to_string()[..8]
);
let repo = self.client.create_repo_announcement(&test_name).await?;
self.client.send_event(repo.clone()).await?;
let issue = self.client.create_issue(
&repo,
"Test Issue",
"Issue content",
vec![],
)?;
let issue =
self.client
.create_issue(&repo, "Test Issue", "Issue content", vec![])?;
self.client.send_event(issue.clone()).await?;
// Then create comment on issue
self.client.create_comment(
&issue,
"Test comment",
vec![],
)
self.client.create_comment(&issue, "Test comment", vec![])
}
FixtureKind::RepoState => {
use nostr_sdk::prelude::*;
// First create repo announcement
let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]);
let test_name = format!(
"fixture-{:?}-{}",
FixtureKind::ValidRepo,
&uuid::Uuid::new_v4().to_string()[..8]
);
let repo = self.client.create_repo_announcement(&test_name).await?;
self.client.send_event(repo.clone()).await?;
// Extract repo_id from repo announcement
let repo_id = repo.tags.iter()
let repo_id = repo
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))?
.to_string();
// Create state announcement
self.client.event_builder(Kind::Custom(30618), "")
self.client
.event_builder(Kind::Custom(30618), "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![
"abc123def456789012345678901234567890abcd"
]))
.tag(Tag::custom(TagKind::custom("HEAD"), vec![
"ref: refs/heads/main"
]))
.tag(Tag::custom(
TagKind::custom("refs/heads/main"),
vec!["abc123def456789012345678901234567890abcd"],
))
.tag(Tag::custom(
TagKind::custom("HEAD"),
vec!["ref: refs/heads/main"],
))
.build(self.client.keys())
.map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))
}
}
}
/// Clear the fixture cache
///
/// This is useful for tests that want to ensure fresh fixtures
@@ -286,34 +311,37 @@ impl<'a> TestContext<'a> {
mod tests {
use super::*;
use crate::AuditConfig;
#[test]
fn test_context_mode_from_audit_mode() {
assert_eq!(ContextMode::from(AuditMode::CI), ContextMode::Isolated);
assert_eq!(ContextMode::from(AuditMode::Production), ContextMode::Shared);
assert_eq!(
ContextMode::from(AuditMode::Production),
ContextMode::Shared
);
}
#[test]
fn test_fixture_kind_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(FixtureKind::ValidRepo);
set.insert(FixtureKind::RepoWithIssue);
assert!(set.contains(&FixtureKind::ValidRepo));
assert!(!set.contains(&FixtureKind::RepoWithComment));
}
#[tokio::test]
async fn test_context_creation() {
let config = AuditConfig::ci();
let client = crate::AuditClient::new_test(config);
let ctx = TestContext::new(&client);
assert_eq!(ctx.mode(), ContextMode::Isolated);
let ctx = TestContext::with_mode(&client, ContextMode::Shared);
assert_eq!(ctx.mode(), ContextMode::Shared);
}
}
}
+6 -6
View File
@@ -11,7 +11,7 @@ pub fn generate_test_id() -> String {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
format!("test-{}-{}", timestamp, counter)
}
@@ -26,29 +26,29 @@ pub fn generate_prod_run_id() -> String {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
format!("prod-audit-{}", timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_test_id() {
let id1 = generate_test_id();
let id2 = generate_test_id();
assert_ne!(id1, id2);
assert!(id1.starts_with("test-"));
}
#[test]
fn test_generate_ci_run_id() {
let id = generate_ci_run_id();
assert!(id.starts_with("ci-"));
}
#[test]
fn test_generate_prod_run_id() {
let id = generate_prod_run_id();
+2 -2
View File
@@ -29,15 +29,15 @@
//! ```
pub mod audit;
pub mod fixtures;
pub mod client;
pub mod fixtures;
pub mod isolation;
pub mod result;
pub mod specs;
pub use audit::{AuditConfig, AuditMode};
pub use fixtures::{ContextMode, FixtureKind, TestContext};
pub use client::AuditClient;
pub use fixtures::{ContextMode, FixtureKind, TestContext};
pub use result::{AuditResult, TestResult};
// Re-export commonly used types
+29 -28
View File
@@ -25,7 +25,7 @@ impl TestResult {
duration: Duration::default(),
}
}
/// Run a test function and capture the result
pub async fn run<F, Fut>(mut self, test_fn: F) -> Self
where
@@ -33,7 +33,7 @@ impl TestResult {
Fut: std::future::Future<Output = Result<(), String>>,
{
let start = Instant::now();
match test_fn().await {
Ok(()) => {
self.passed = true;
@@ -43,17 +43,17 @@ impl TestResult {
self.error = Some(e);
}
}
self.duration = start.elapsed();
self
}
/// Mark test as passed
pub fn pass(mut self) -> Self {
self.passed = true;
self
}
/// Mark test as failed with error
pub fn fail(mut self, error: impl Into<String>) -> Self {
self.passed = false;
@@ -77,68 +77,69 @@ impl AuditResult {
results: Vec::new(),
}
}
/// Add a test result
pub fn add(&mut self, result: TestResult) {
self.results.push(result);
}
/// Merge another audit result
pub fn merge(&mut self, other: AuditResult) {
self.results.extend(other.results);
}
/// Check if all tests passed
pub fn all_passed(&self) -> bool {
self.results.iter().all(|r| r.passed)
}
/// Get count of passed tests
pub fn passed_count(&self) -> usize {
self.results.iter().filter(|r| r.passed).count()
}
/// Get count of failed tests
pub fn failed_count(&self) -> usize {
self.results.iter().filter(|r| !r.passed).count()
}
/// Get total count of tests
pub fn total_count(&self) -> usize {
self.results.len()
}
/// Print a detailed report
pub fn print_report(&self) {
println!("\n{}", self.spec);
println!("{}", "═".repeat(60));
println!();
let passed = self.passed_count();
let total = self.total_count();
for result in &self.results {
let status = if result.passed { "✓" } else { "✗" };
println!("{} {} ({})", status, result.name, result.spec_ref);
println!(" Requirement: {}", result.requirement);
if let Some(error) = &result.error {
println!(" Error: {}", error);
}
println!(" Duration: {:?}", result.duration);
println!();
}
println!("Results: {}/{} passed ({:.1}%)",
passed,
println!(
"Results: {}/{} passed ({:.1}%)",
passed,
total,
(passed as f64 / total as f64) * 100.0
);
println!();
}
/// Get a summary string
pub fn summary(&self) -> String {
format!(
@@ -153,34 +154,34 @@ impl AuditResult {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_result_pass() {
let result = TestResult::new("test", "SPEC:1", "Must work")
.run(|| async { Ok(()) })
.await;
assert!(result.passed);
assert!(result.error.is_none());
}
#[tokio::test]
async fn test_result_fail() {
let result = TestResult::new("test", "SPEC:1", "Must work")
.run(|| async { Err("Failed".to_string()) })
.await;
assert!(!result.passed);
assert_eq!(result.error, Some("Failed".to_string()));
}
#[test]
fn test_audit_result() {
let mut audit = AuditResult::new("Test Spec");
audit.add(TestResult::new("test1", "SPEC:1", "Req1").pass());
audit.add(TestResult::new("test2", "SPEC:2", "Req2").fail("Error"));
assert_eq!(audit.total_count(), 2);
assert_eq!(audit.passed_count(), 1);
assert_eq!(audit.failed_count(), 1);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,4 +6,4 @@ pub mod nip11_document;
pub use event_acceptance_policy::EventAcceptancePolicyTests;
pub use nip01_smoke::Nip01SmokeTests;
pub use nip11_document::Nip11DocumentTests;
pub use nip11_document::Nip11DocumentTests;
+59 -53
View File
@@ -13,7 +13,7 @@ impl Nip01SmokeTests {
/// Run all NIP-01 smoke tests
pub async fn run_all(client: &AuditClient) -> AuditResult {
let mut results = AuditResult::new("NIP-01 Smoke Tests");
// Run tests sequentially to avoid future type issues
results.add(Self::test_websocket_connection(client).await);
results.add(Self::test_send_receive_event(client).await);
@@ -21,10 +21,10 @@ impl Nip01SmokeTests {
results.add(Self::test_close_subscription(client).await);
results.add(Self::test_reject_invalid_signature(client).await);
results.add(Self::test_reject_invalid_event_id(client).await);
results
}
/// Test 1: Can establish WebSocket connection
///
/// Spec: NIP-01 basic requirement
@@ -39,17 +39,17 @@ impl Nip01SmokeTests {
if !client.is_connected().await {
return Err("Failed to connect to relay".to_string());
}
Ok(())
})
.await
}
/// Test 2: Can send EVENT and receive OK response
///
/// Spec: NIP-01 EVENT message
/// Requirement: Relay MUST accept valid EVENT messages
///
///
/// For GRASP servers, we send a NIP-34 repository announcement that lists
/// the GRASP server in clone and relays tags (required for acceptance).
async fn test_send_receive_event(client: &AuditClient) -> TestResult {
@@ -60,15 +60,17 @@ impl Nip01SmokeTests {
)
.run(|| async {
// Create a NIP-34 announcement event
let event = client.create_repo_announcement("send_receive_event").await
let event = client
.create_repo_announcement("send_receive_event")
.await
.map_err(|e| format!("Failed to create announcement: {}", e))?;
// Send event
let event_id = client
.send_event(event.clone())
.await
.map_err(|e| format!("Failed to send event: {}", e))?;
// Verify we got an event ID back
if event_id != event.id {
return Err(format!(
@@ -76,43 +78,47 @@ impl Nip01SmokeTests {
event.id, event_id
));
}
// Wait a bit for event to be indexed
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Try to query it back
let filter = Filter::new()
.kind(Kind::Custom(30617))
.id(event_id);
let filter = Filter::new().kind(Kind::Custom(30617)).id(event_id);
let events = client
.query(filter)
.await
.map_err(|e| format!("Failed to query event: {}", e))?;
if events.is_empty() {
// Debug: try querying without audit client filtering
eprintln!("Event not found with audit client query, trying direct client query...");
let direct_filter = Filter::new().kind(Kind::Custom(30617)).id(event_id);
let direct_events = client.client().fetch_events(direct_filter, std::time::Duration::from_secs(5)).await
let direct_events = client
.client()
.fetch_events(direct_filter, std::time::Duration::from_secs(5))
.await
.map_err(|e| format!("Direct query failed: {}", e))?;
let direct_vec: Vec<Event> = direct_events.into_iter().collect();
eprintln!("Direct query found {} events", direct_vec.len());
if !direct_vec.is_empty() {
eprintln!("Event tags: {:?}", direct_vec[0].tags);
}
return Err(format!("Event not found after sending (direct query found {})", direct_vec.len()));
return Err(format!(
"Event not found after sending (direct query found {})",
direct_vec.len()
));
}
if events[0].id != event_id {
return Err("Retrieved event has different ID".to_string());
}
Ok(())
})
.await
}
/// Test 3: Can create subscription with REQ
///
/// Spec: NIP-01 REQ message
@@ -125,34 +131,36 @@ impl Nip01SmokeTests {
)
.run(|| async {
// Create a NIP-34 announcement event (accepted by GRASP relays)
let event = client.create_repo_announcement("create_subscription").await
let event = client
.create_repo_announcement("create_subscription")
.await
.map_err(|e| format!("Failed to create announcement: {}", e))?;
let event_id = client
let _event_id = client
.send_event(event.clone())
.await
.map_err(|e| format!("Failed to send event: {}", e))?;
// Subscribe to NIP-34 announcements from this author
let filter = Filter::new()
.kind(Kind::Custom(30617))
.author(client.public_key());
let events = client
.subscribe(vec![filter], Some(std::time::Duration::from_secs(5)))
.await
.map_err(|e| format!("Failed to subscribe: {}", e))?;
// Should have at least our event
if events.is_empty() {
return Err("No events received from subscription".to_string());
}
Ok(())
})
.await
}
/// Test 4: Can close subscription with CLOSE
///
/// Spec: NIP-01 CLOSE message
@@ -167,22 +175,20 @@ impl Nip01SmokeTests {
// For now, we just verify we can query events
// Full subscription management with CLOSE would require
// lower-level WebSocket access
let filter = Filter::new()
.kind(Kind::TextNote)
.limit(1);
let filter = Filter::new().kind(Kind::TextNote).limit(1);
let _events = client
.subscribe(vec![filter], Some(std::time::Duration::from_secs(2)))
.await
.map_err(|e| format!("Failed to subscribe: {}", e))?;
// If we got here, subscription worked
Ok(())
})
.await
}
/// Test 5: Rejects events with invalid signatures
///
/// Spec: NIP-01 event validation
@@ -199,7 +205,7 @@ impl Nip01SmokeTests {
.event_builder(Kind::TextNote, "Invalid signature test")
.build(client.keys())
.map_err(|e| format!("Failed to build event: {}", e))?;
// Corrupt the signature by creating a new event with wrong sig
// We'll use a different key to sign, creating an invalid signature
let wrong_keys = Keys::generate();
@@ -207,7 +213,7 @@ impl Nip01SmokeTests {
.tags(event.tags.clone())
.sign_with_keys(&wrong_keys)
.map_err(|e| format!("Failed to build wrong event: {}", e))?;
// Create event JSON with mismatched pubkey and signature
// This should be rejected by the relay
let invalid_event_json = serde_json::json!({
@@ -219,24 +225,24 @@ impl Nip01SmokeTests {
"content": event.content,
"sig": wrong_event.sig.to_string(), // Wrong signature!
});
// Parse it back to an Event
let invalid_event: Event = serde_json::from_value(invalid_event_json)
.map_err(|e| format!("Failed to create invalid event: {}", e))?;
// Try to send the invalid event
let result = client.send_event(invalid_event).await;
// We expect this to fail
if result.is_ok() {
return Err("Relay accepted event with invalid signature".to_string());
}
Ok(())
})
.await
}
/// Test 6: Rejects events with invalid event IDs
///
/// Spec: NIP-01 event ID validation
@@ -253,7 +259,7 @@ impl Nip01SmokeTests {
.event_builder(Kind::TextNote, "Invalid ID test")
.build(client.keys())
.map_err(|e| format!("Failed to build event: {}", e))?;
// Create event JSON with corrupted ID
let invalid_event_json = serde_json::json!({
"id": EventId::all_zeros().to_hex(), // Wrong ID!
@@ -264,19 +270,19 @@ impl Nip01SmokeTests {
"content": event.content,
"sig": event.sig.to_string(),
});
// Parse it back to an Event
let invalid_event: Event = serde_json::from_value(invalid_event_json)
.map_err(|e| format!("Failed to create invalid event: {}", e))?;
// Try to send the invalid event
let result = client.send_event(invalid_event).await;
// We expect this to fail
if result.is_ok() {
return Err("Relay accepted event with invalid ID".to_string());
}
Ok(())
})
.await
@@ -287,25 +293,25 @@ impl Nip01SmokeTests {
mod tests {
use super::*;
use crate::AuditConfig;
// Note: These tests require a running relay
// They are integration tests, not unit tests
#[tokio::test]
#[ignore] // Ignore by default since it needs a running relay
async fn test_smoke_tests_against_relay() {
// RELAY_URL env var must be set - no default fallback
let relay_url = std::env::var("RELAY_URL")
.expect("RELAY_URL environment variable must be set for integration tests");
let config = AuditConfig::ci();
let client = AuditClient::new(&relay_url, config)
.await
.expect("Failed to connect to relay");
let results = Nip01SmokeTests::run_all(&client).await;
results.print_report();
assert!(results.all_passed(), "Some smoke tests failed");
}
}
+36 -35
View File
@@ -9,7 +9,6 @@
//! - Handles curation field correctly (present if curated, absent otherwise)
use crate::{AuditClient, AuditResult, TestResult};
use nostr_sdk::prelude::*;
pub struct Nip11DocumentTests;
@@ -17,25 +16,25 @@ impl Nip11DocumentTests {
/// Run all NIP-11 document tests
pub async fn run_all(client: &AuditClient) -> AuditResult {
let mut results = AuditResult::new("GRASP-01 NIP-11 Document Tests");
// NIP-11 relay information tests
results.add(Self::test_nip11_document_exists(client).await);
results.add(Self::test_nip11_supported_grasps_field(client).await);
results.add(Self::test_nip11_repo_acceptance_criteria_field(client).await);
results.add(Self::test_nip11_curation_field(client).await);
results
}
// =========================================================================
// NIP-11 Relay Information Tests
// =========================================================================
/// Test: Serve NIP-11 document
///
/// Spec: Line 11 of ../grasp/01.md
/// Requirement: MUST serve NIP-11 document
async fn test_nip11_document_exists(client: &AuditClient) -> TestResult {
async fn test_nip11_document_exists(_client: &AuditClient) -> TestResult {
TestResult::new(
"nip11_document_exists",
"GRASP-01:nostr-relay:11",
@@ -52,17 +51,17 @@ impl Nip11DocumentTests {
// 4. Verify response is valid JSON
// 5. Parse as NIP-11 document
// 6. Verify has required fields (name, description, etc.)
Err("Not implemented yet".to_string())
})
.await
}
/// Test: NIP-11 includes supported_grasps field
///
/// Spec: Line 12 of ../grasp/01.md
/// Requirement: MUST list supported GRASPs as string array
async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult {
async fn test_nip11_supported_grasps_field(_client: &AuditClient) -> TestResult {
TestResult::new(
"nip11_supported_grasps_field",
"GRASP-01:nostr-relay:12",
@@ -76,17 +75,17 @@ impl Nip11DocumentTests {
// 4. Verify array includes "GRASP-01"
// 5. Verify format: each entry matches pattern "GRASP-\d{2}"
// 6. Document other GRASPs found (for info)
Err("Not implemented yet".to_string())
})
.await
}
/// Test: NIP-11 includes repo_acceptance_criteria field
///
/// Spec: Line 13 of ../grasp/01.md
/// Requirement: MUST list repository acceptance criteria
async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult {
async fn test_nip11_repo_acceptance_criteria_field(_client: &AuditClient) -> TestResult {
TestResult::new(
"nip11_repo_acceptance_criteria_field",
"GRASP-01:nostr-relay:13",
@@ -101,17 +100,17 @@ impl Nip11DocumentTests {
// 5. Document the criteria (for info)
// Examples: "Must list this relay in clone and relays tags"
// "Pre-payment required via Lightning invoice"
Err("Not implemented yet".to_string())
})
.await
}
/// Test: NIP-11 curation field handling
///
/// Spec: Line 14 of ../grasp/01.md
/// Requirement: MUST include curation if curated, omit otherwise
async fn test_nip11_curation_field(client: &AuditClient) -> TestResult {
async fn test_nip11_curation_field(_client: &AuditClient) -> TestResult {
TestResult::new(
"nip11_curation_field",
"GRASP-01:nostr-relay:14",
@@ -127,39 +126,41 @@ impl Nip11DocumentTests {
// 4. If absent:
// - Document that no curation beyond SPAM prevention
// 5. Both cases are valid per spec
Err("Not implemented yet".to_string())
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AuditConfig;
#[tokio::test]
#[ignore] // Requires running relay
async fn test_grasp01_nip11_document_against_relay() {
// Read relay URL from environment variable - must be supplied
let relay_url = std::env::var("RELAY_URL")
.expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081");
let config = AuditConfig::ci();
let client = AuditClient::new(&relay_url, config)
.await
.expect(&format!(
"Failed to connect to relay at {}. Ensure relay is running and accessible. \
#[tokio::test]
#[ignore] // Requires running relay
async fn test_grasp01_nip11_document_against_relay() {
// Read relay URL from environment variable - must be supplied
let relay_url = std::env::var("RELAY_URL").expect(
"RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081",
);
let config = AuditConfig::ci();
let client = AuditClient::new(&relay_url, config)
.await
.unwrap_or_else(|_| {
panic!(
"Failed to connect to relay at {}. Ensure relay is running and accessible. \
Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest",
relay_url
));
relay_url
)
});
let results = Nip11DocumentTests::run_all(&client).await;
results.print_report();
// Don't assert all passed yet - tests not implemented
// assert!(results.all_passed(), "Some GRASP-01 NIP-11 document tests failed");
}
}
}
+1 -5
View File
@@ -3,8 +3,4 @@
pub mod grasp01;
// Re-export all test structs from grasp01 module
pub use grasp01::{
EventAcceptancePolicyTests,
Nip01SmokeTests,
Nip11DocumentTests,
};
pub use grasp01::{EventAcceptancePolicyTests, Nip01SmokeTests, Nip11DocumentTests};
+2 -4
View File
@@ -19,10 +19,8 @@ impl Config {
dotenvy::dotenv().ok();
Ok(Config {
domain: env::var("NGIT_DOMAIN")
.unwrap_or_else(|_| "localhost:8080".to_string()),
owner_npub: env::var("NGIT_OWNER_NPUB")
.context("NGIT_OWNER_NPUB must be set")?,
domain: env::var("NGIT_DOMAIN").unwrap_or_else(|_| "localhost:8080".to_string()),
owner_npub: env::var("NGIT_OWNER_NPUB").context("NGIT_OWNER_NPUB must be set")?,
relay_name: env::var("NGIT_RELAY_NAME")
.unwrap_or_else(|_| "ngit-grasp relay".to_string()),
relay_description: env::var("NGIT_RELAY_DESCRIPTION")
+9 -6
View File
@@ -1,7 +1,6 @@
/// Landing Page Handler
///
///
/// Serves the HTML landing page or upgrades to WebSocket for Nostr relay connections.
use actix_web::{web, HttpRequest, HttpResponse, Result};
use nostr_relay_builder::LocalRelay;
@@ -16,12 +15,16 @@ pub async fn handle(
) -> Result<HttpResponse> {
// Check if this is a WebSocket upgrade request
if let Some(upgrade) = req.headers().get("upgrade") {
if upgrade.to_str().unwrap_or("").eq_ignore_ascii_case("websocket") {
if upgrade
.to_str()
.unwrap_or("")
.eq_ignore_ascii_case("websocket")
{
// Delegate to WebSocket handler
return crate::http::websocket::handle(req, stream, relay).await;
}
}
// Otherwise, serve the landing page
let html = format!(
include_str!("../../templates/landing.html"),
@@ -30,8 +33,8 @@ pub async fn handle(
domain = config.domain,
bind_address = config.bind_address,
);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}
}
+5 -6
View File
@@ -1,7 +1,6 @@
/// HTTP Server Module
///
///
/// Provides actix-web HTTP server with WebSocket upgrade support for the Nostr relay.
pub mod landing;
pub mod websocket;
@@ -13,9 +12,9 @@ use crate::config::Config;
/// Start the HTTP server with integrated Nostr relay
pub async fn run_server(config: Config, relay: LocalRelay) -> anyhow::Result<()> {
let bind_addr = config.bind_address.clone();
tracing::info!("Starting HTTP server on {}", bind_addr);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(config.clone()))
@@ -26,6 +25,6 @@ pub async fn run_server(config: Config, relay: LocalRelay) -> anyhow::Result<()>
.bind(&bind_addr)?
.run()
.await?;
Ok(())
}
}
+12 -12
View File
@@ -1,8 +1,7 @@
/// WebSocket Handler
///
///
/// Handles WebSocket upgrade requests and passes connections to the Nostr relay.
use actix_web::{web, HttpRequest, HttpResponse, Result, Error};
use actix_web::{web, Error, HttpRequest, HttpResponse, Result};
use actix_ws::Message;
use futures_util::StreamExt;
use nostr_relay_builder::LocalRelay;
@@ -14,19 +13,20 @@ pub async fn handle(
relay: web::Data<LocalRelay>,
) -> Result<HttpResponse, Error> {
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?;
let peer_addr = req.peer_addr()
let peer_addr = req
.peer_addr()
.unwrap_or_else(|| "0.0.0.0:0".parse().unwrap());
tracing::debug!("WebSocket connection from {}", peer_addr);
// Spawn task to handle the WebSocket connection
// TODO: Will use relay.take_connection() for full Nostr relay integration
let _relay = relay.get_ref().clone();
actix_web::rt::spawn(async move {
// Create a channel to communicate between actix-ws and relay
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
// Spawn task to send messages from relay to client
let mut session_clone = session.clone();
actix_web::rt::spawn(async move {
@@ -36,7 +36,7 @@ pub async fn handle(
}
}
});
// Handle incoming messages from client
while let Some(Ok(msg)) = msg_stream.next().await {
match msg {
@@ -65,9 +65,9 @@ pub async fn handle(
Message::Nop => {}
}
}
tracing::debug!("WebSocket connection closed for {}", peer_addr);
});
Ok(response)
}
}
+29 -45
View File
@@ -1,12 +1,11 @@
/// NIP-34 Git Repository Event Handling
///
/// This module handles Git repository announcements (kind 30617) and
///
/// 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};
@@ -138,8 +137,8 @@ impl RepositoryAnnouncement {
}
/// Check if this announcement lists the service (both clone and relay)
///
/// GRASP-01 requirement: MUST reject announcements that do not list
///
/// 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)
@@ -278,19 +277,19 @@ impl RepositoryState {
}
/// 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));
return Err(anyhow!(
"Invalid kind: expected {}",
KIND_REPOSITORY_ANNOUNCEMENT
));
}
// Must have identifier
let has_identifier = event
.tags
.iter()
.any(|t| t.kind() == TagKind::d());
let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d());
if !has_identifier {
return Err(anyhow!("Missing required 'd' tag (identifier)"));
}
@@ -298,7 +297,7 @@ pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
// Parse full announcement to validate structure
let announcement = RepositoryAnnouncement::from_event(event.clone())?;
// GRASP-01: MUST reject announcements that do not list the service
// 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!(
@@ -313,7 +312,7 @@ pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
}
/// 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
@@ -322,10 +321,7 @@ pub fn validate_state(event: &Event) -> Result<()> {
}
// Must have identifier
let has_identifier = event
.tags
.iter()
.any(|t| t.kind() == TagKind::d());
let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d());
if !has_identifier {
return Err(anyhow!("Missing required 'd' tag (identifier)"));
}
@@ -352,7 +348,7 @@ mod tests {
relays: Vec<&str>,
) -> Event {
use nostr_sdk::Tag;
let mut tags = vec![Tag::custom(
nostr_sdk::TagKind::d(),
vec![identifier.to_string()],
@@ -372,18 +368,15 @@ mod tests {
));
}
EventBuilder::new(
Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
"Test repository",
)
.tags(tags)
.sign_with_keys(keys)
.unwrap()
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()],
@@ -392,10 +385,7 @@ mod tests {
for (branch, commit) in branches {
tags.push(Tag::custom(
nostr_sdk::TagKind::Custom("ref".into()),
vec![
format!("refs/heads/{}", branch),
commit.to_string(),
],
vec![format!("refs/heads/{}", branch), commit.to_string()],
));
}
@@ -428,12 +418,9 @@ mod tests {
#[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 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());
@@ -539,7 +526,7 @@ mod tests {
#[test]
fn test_announcement_maintainers() {
use nostr_sdk::Tag;
let keys = create_test_keys();
let maintainer_keys = create_test_keys();
@@ -558,13 +545,10 @@ mod tests {
// 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 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);
@@ -573,7 +557,7 @@ mod tests {
#[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(),
+8 -12
View File
@@ -59,11 +59,7 @@ impl TestRelay {
.spawn()
.expect("Failed to start relay process");
let relay = Self {
process,
url,
port,
};
let relay = Self { process, url, port };
// Wait for relay to be ready
relay.wait_for_ready().await;
@@ -126,18 +122,18 @@ impl TestRelay {
/// Find a free port to use for testing
fn find_free_port() -> u16 {
use std::net::TcpListener;
// Bind to port 0 to get a random free port
let listener = TcpListener::bind("127.0.0.1:0")
.expect("Failed to bind to random port");
let port = listener.local_addr()
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
let port = listener
.local_addr()
.expect("Failed to get local address")
.port();
// Drop the listener to free the port
drop(listener);
port
}
}