add repository creation

This commit is contained in:
DanConwayDev
2025-11-21 05:34:56 +00:00
parent 7dda553918
commit 7a81643367
8 changed files with 557 additions and 11 deletions
Generated
+2
View File
@@ -684,6 +684,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-test",
@@ -1217,6 +1218,7 @@ dependencies = [
"nostr-sdk 0.44.1",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-test",
+1
View File
@@ -49,6 +49,7 @@ thiserror = "1.0"
tokio-test = "0.4"
grasp-audit = { path = "grasp-audit" }
url = "2.5"
tempfile = "3"
[lib]
name = "ngit_grasp"
+1
View File
@@ -43,3 +43,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tokio-test = "0.4"
tempfile = "3"
+2
View File
@@ -3,7 +3,9 @@
pub mod event_acceptance_policy;
pub mod nip01_smoke;
pub mod nip11_document;
pub mod repository_creation;
pub use event_acceptance_policy::EventAcceptancePolicyTests;
pub use nip01_smoke::Nip01SmokeTests;
pub use nip11_document::Nip11DocumentTests;
pub use repository_creation::RepositoryCreationTests;
@@ -0,0 +1,378 @@
//! GRASP-01 Repository Creation Tests
//!
//! Tests that verify bare Git repositories are created when repository announcements
//! are accepted by the relay.
//!
//! ## Test Coverage
//!
//! - Repository creation on valid announcement
//! - Idempotent creation (no error if repo already exists)
//! - Proper directory structure (<npub>/<identifier>.git)
//! - Bare repository validation (has HEAD, config, objects, refs)
//!
//! ## Running Tests
//!
//! ```bash
//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
//! ```
use crate::{AuditClient, TestContext, FixtureKind, TestResult};
use nostr_sdk::prelude::*;
use std::path::Path;
/// Test suite for repository creation
pub struct RepositoryCreationTests;
impl RepositoryCreationTests {
/// Test that a bare repository is created when a valid announcement is accepted
///
/// This test:
/// 1. Sends a valid repository announcement via TestContext
/// 2. Verifies the announcement was accepted
/// 3. Checks that a bare git repository was created at the expected path
pub async fn test_bare_repo_created_on_announcement(
client: &AuditClient,
git_data_dir: &Path,
) -> TestResult {
let test_name = "test_bare_repo_created_on_announcement";
let ctx = TestContext::new(client);
// Use TestContext to create and send repository announcement
let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
Ok(r) => r,
Err(e) => return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must be created when announcement is accepted",
).fail(&format!("Failed to create repo fixture: {}", e)),
};
// Wait a bit for repository creation
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Extract repo identifier and npub from announcement
let repo_id = match repo.tags.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
{
Some(id) => id.to_string(),
None => return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must be created when announcement is accepted",
).fail("Repository announcement missing d tag"),
};
let npub = match repo.pubkey.to_bech32() {
Ok(n) => n,
Err(e) => return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must be created when announcement is accepted",
).fail(&format!("Failed to convert pubkey to npub: {}", e)),
};
// Check if repository was created
let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
if !is_bare_repository(&repo_path) {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must be created when announcement is accepted",
).fail(&format!("Bare repository not found at: {}", repo_path.display()));
}
TestResult::new(
test_name,
"GRASP-01",
"Bare repository must be created when announcement is accepted",
).pass()
}
/// Test that repository creation is idempotent
///
/// This test:
/// 1. Sends a repository announcement (creates repo) via TestContext
/// 2. Sends the same announcement again
/// 3. Verifies no error occurs and repo still exists
pub async fn test_repo_creation_idempotent(
client: &AuditClient,
git_data_dir: &Path,
) -> TestResult {
let test_name = "test_repo_creation_idempotent";
let ctx = TestContext::new(client);
// Create and send repository announcement first time via TestContext
let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
Ok(r) => r,
Err(e) => return TestResult::new(
test_name,
"GRASP-01",
"Repository creation must be idempotent",
).fail(&format!("Failed to create repo fixture: {}", e)),
};
// Wait for repository creation
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Send the same announcement again (should be idempotent)
if let Err(e) = client.send_event(repo.clone()).await {
return TestResult::new(
test_name,
"GRASP-01",
"Repository creation must be idempotent",
).fail(&format!("Second send failed (not idempotent): {}", e));
}
// Wait again
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Verify repository still exists and is valid
let repo_id = repo.tags.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or("Missing d tag")
.unwrap()
.to_string();
let npub = repo.pubkey.to_bech32().unwrap();
let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
if !is_bare_repository(&repo_path) {
return TestResult::new(
test_name,
"GRASP-01",
"Repository creation must be idempotent",
).fail("Repository not found after second send");
}
TestResult::new(
test_name,
"GRASP-01",
"Repository creation must be idempotent",
).pass()
}
/// Test that the repository has the correct structure
///
/// This test verifies:
/// 1. Repository is at <git_data_path>/<npub>/<identifier>.git
/// 2. Repository is bare (no working directory)
/// 3. Repository has required git structure (HEAD, config, objects/, refs/)
pub async fn test_bare_repo_structure(
client: &AuditClient,
git_data_dir: &Path,
) -> TestResult {
let test_name = "test_bare_repo_structure";
let ctx = TestContext::new(client);
// Create and send repository announcement via TestContext
let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
Ok(r) => r,
Err(e) => return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail(&format!("Failed to create repo fixture: {}", e)),
};
// Wait for repository creation
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Extract repo identifier and npub
let repo_id = repo.tags.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or("Missing d tag")
.unwrap()
.to_string();
let npub = repo.pubkey.to_bech32().unwrap();
// Verify correct path structure: <git_data_path>/<npub>/<identifier>.git
let expected_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
if !expected_path.exists() {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail(&format!("Repository not at expected path: {}", expected_path.display()));
}
// Verify it's a bare repository with correct structure
if !expected_path.join("HEAD").is_file() {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail("Missing HEAD file");
}
if !expected_path.join("config").is_file() {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail("Missing config file");
}
if !expected_path.join("objects").is_dir() {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail("Missing objects/ directory");
}
if !expected_path.join("refs").is_dir() {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail("Missing refs/ directory");
}
// Verify the helper function agrees
if !is_bare_repository(&expected_path) {
return TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).fail("Helper function does not recognize repository as bare");
}
TestResult::new(
test_name,
"GRASP-01",
"Bare repository must have correct structure",
).pass()
}
/// Test that repository creation cleanup works
///
/// This test:
/// 1. Creates multiple repositories via TestContext
/// 2. Verifies they exist
/// 3. Ensures test cleanup removes them (via TempDir drop)
pub async fn test_repo_cleanup(
client: &AuditClient,
git_data_dir: &Path,
) -> TestResult {
let test_name = "test_repo_cleanup";
let ctx = TestContext::new(client);
// Create multiple repositories via TestContext
let mut repo_paths = Vec::new();
for _i in 0..3 {
let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
Ok(r) => r,
Err(e) => return TestResult::new(
test_name,
"GRASP-01",
"Test cleanup must remove created repositories",
).fail(&format!("Failed to create repo fixture: {}", e)),
};
// Extract path
let repo_id = repo.tags.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.unwrap()
.to_string();
let npub = repo.pubkey.to_bech32().unwrap();
let path = git_data_dir.join(&npub).join(format!("{}.git", repo_id));
repo_paths.push(path);
}
// Wait for all repositories to be created
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Verify all repositories exist
for path in &repo_paths {
if !is_bare_repository(path) {
return TestResult::new(
test_name,
"GRASP-01",
"Test cleanup must remove created repositories",
).fail(&format!("Repository not created at: {}", path.display()));
}
}
// Note: Actual cleanup happens when the TestRelay's TempDir is dropped
// This test just verifies that repositories were created successfully
// The integration test framework will verify cleanup
TestResult::new(
test_name,
"GRASP-01",
"Test cleanup must remove created repositories",
).pass()
}
}
/// Helper function to check if a path is a valid bare git repository
///
/// A bare repository must have:
/// - HEAD file
/// - config file
/// - objects/ directory
/// - refs/ directory
pub fn is_bare_repository(path: &Path) -> bool {
if !path.exists() {
return false;
}
// Check for required bare repository components
let has_head = path.join("HEAD").is_file();
let has_config = path.join("config").is_file();
let has_objects = path.join("objects").is_dir();
let has_refs = path.join("refs").is_dir();
has_head && has_config && has_objects && has_refs
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
#[test]
fn test_is_bare_repository_detects_valid_repo() {
// Create a temporary bare repository for testing
let temp_dir = tempfile::tempdir().unwrap();
let repo_path = temp_dir.path().join("test.git");
// Initialize a bare repository
Command::new("git")
.args(&["init", "--bare", repo_path.to_str().unwrap()])
.output()
.expect("Failed to create test repository");
// Verify our helper function detects it
assert!(
is_bare_repository(&repo_path),
"Should detect valid bare repository"
);
}
#[test]
fn test_is_bare_repository_rejects_non_repo() {
let temp_dir = tempfile::tempdir().unwrap();
assert!(
!is_bare_repository(temp_dir.path()),
"Should reject non-repository directory"
);
}
#[test]
fn test_is_bare_repository_rejects_nonexistent() {
let path = Path::new("/nonexistent/path/to/repo.git");
assert!(
!is_bare_repository(path),
"Should reject nonexistent path"
);
}
}
+74 -9
View File
@@ -3,7 +3,7 @@
/// This module integrates nostr-relay-builder with NIP-34 validation logic
/// preserved from the original implementation.
use std::net::SocketAddr;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use nostr::nips::nip19::ToBech32;
@@ -13,7 +13,7 @@ use nostr_relay_builder::prelude::*;
use crate::config::{Config, DatabaseBackend};
use crate::nostr::events::{
validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
validate_announcement, validate_state, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
};
/// NIP-34 Write Policy with Full GRASP-01 Event Validation
@@ -30,16 +30,53 @@ use crate::nostr::events::{
pub struct Nip34WritePolicy {
domain: String,
database: Arc<MemoryDatabase>,
git_data_path: PathBuf,
}
impl Nip34WritePolicy {
pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>) -> Self {
pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>, git_data_path: impl Into<PathBuf>) -> Self {
Self {
domain: domain.into(),
database,
git_data_path: git_data_path.into(),
}
}
/// Create a bare git repository if it doesn't exist
/// Path format: <git_data_path>/<npub>/<identifier>.git
fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
let repo_path = self.git_data_path.join(&announcement.repo_path());
// Check if repository already exists
if repo_path.exists() {
tracing::debug!("Repository already exists at {}", repo_path.display());
return Ok(());
}
// Create parent directory (npub directory)
let parent = repo_path.parent().ok_or_else(|| {
format!("Invalid repository path: {}", repo_path.display())
})?;
std::fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create directory {}: {}", parent.display(), e)
})?;
// Initialize bare repository using git command
let output = std::process::Command::new("git")
.args(&["init", "--bare", repo_path.to_str().unwrap()])
.output()
.map_err(|e| format!("Failed to execute git init: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git init failed: {}", stderr));
}
tracing::info!("Created bare repository at {}", repo_path.display());
Ok(())
}
/// Extract all reference tags from an event (a, A, q, e, E)
/// Returns (addressable_refs, event_refs)
fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
@@ -269,11 +306,35 @@ impl WritePolicy for Nip34WritePolicy {
match event.kind.as_u16() {
KIND_REPOSITORY_ANNOUNCEMENT => match validate_announcement(event, &domain) {
Ok(_) => {
tracing::debug!(
"Accepted repository announcement: {}",
event_id_str
);
PolicyResult::Accept
// Parse announcement to get repository details
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
// Try to create bare repository if it doesn't exist
if let Err(e) = self.ensure_bare_repository(&announcement) {
tracing::warn!(
"Failed to create bare repository for {}: {}",
event_id_str,
e
);
// Note: We still accept the event even if repo creation fails
// The git operation failure shouldn't prevent event acceptance
}
tracing::debug!(
"Accepted repository announcement: {}",
event_id_str
);
PolicyResult::Accept
}
Err(e) => {
tracing::warn!(
"Failed to parse repository announcement {}: {}",
event_id_str,
e
);
PolicyResult::Reject(format!("Failed to parse announcement: {}", e))
}
}
}
Err(e) => {
tracing::warn!(
@@ -432,7 +493,11 @@ pub fn create_relay(config: &Config) -> Result<LocalRelay> {
// Clone Arc for the write policy so both relay and policy can access the database
let builder = RelayBuilder::default()
.database(database.clone())
.write_policy(Nip34WritePolicy::new(&config.domain, database.clone()));
.write_policy(Nip34WritePolicy::new(
&config.domain,
database.clone(),
&config.git_data_path,
));
tracing::info!(
"Relay configured with GRASP-01 validation for domain: {}",
+35 -2
View File
@@ -2,6 +2,8 @@
//!
//! Provides automatic relay lifecycle management for integration tests.
use nostr_sdk::ToBech32;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::time::sleep;
@@ -9,11 +11,12 @@ use tokio::time::sleep;
/// Test relay fixture that manages relay lifecycle
///
/// Automatically starts and stops the ngit-grasp relay for testing.
/// Uses a random port to avoid conflicts.
/// Uses a random port to avoid conflicts and cleans up created repositories.
pub struct TestRelay {
process: Child,
url: String,
port: u16,
git_data_dir: tempfile::TempDir,
}
impl TestRelay {
@@ -40,6 +43,10 @@ impl TestRelay {
let bind_address = format!("127.0.0.1:{}", port);
let url = format!("ws://127.0.0.1:{}", port);
// Create temporary directory for git repositories
let git_data_dir = tempfile::tempdir()
.expect("Failed to create temporary git data directory");
// Use the built binary directly (faster than cargo run)
let binary_path = std::env::current_exe()
.expect("Failed to get current exe")
@@ -49,17 +56,29 @@ impl TestRelay {
.expect("Failed to get grandparent dir")
.join("ngit-grasp");
// Generate a test owner npub (using a random keypair)
let test_keys = nostr_sdk::Keys::generate();
let test_npub = test_keys.public_key().to_bech32()
.expect("Failed to generate test npub");
// Start the relay process
let process = Command::new(&binary_path)
.env("NGIT_BIND_ADDRESS", &bind_address)
.env("NGIT_DOMAIN", &bind_address) // Set domain to match bind address
.env("NGIT_GIT_DATA_PATH", git_data_dir.path())
.env("NGIT_OWNER_NPUB", &test_npub)
.env("RUST_LOG", "warn") // Less logging during tests
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("Failed to start relay process");
let relay = Self { process, url, port };
let relay = Self {
process,
url,
port,
git_data_dir,
};
// Wait for relay to be ready
relay.wait_for_ready().await;
@@ -82,6 +101,20 @@ impl TestRelay {
format!("127.0.0.1:{}", self.port)
}
/// Get the git data directory path
pub fn git_data_dir(&self) -> &std::path::Path {
self.git_data_dir.path()
}
/// Get the expected repository path for a given npub and repo identifier
///
/// Repositories are stored at: <git_data_dir>/<npub>/<identifier>.git
pub fn repo_path(&self, npub: &str, identifier: &str) -> PathBuf {
self.git_data_dir.path()
.join(npub)
.join(format!("{}.git", identifier))
}
/// Wait for the relay to be ready to accept connections
async fn wait_for_ready(&self) {
let max_attempts = 50; // 5 seconds total
+64
View File
@@ -0,0 +1,64 @@
//! Repository Creation Integration Tests
//!
//! Tests that verify bare Git repositories are created when repository announcements
//! are accepted by ngit-grasp relay.
//!
//! # Test Strategy
//!
//! - Each test runs in complete isolation with its own fresh relay instance
//! - Uses macro to eliminate boilerplate while maintaining test isolation
//! - Calls individual test methods from grasp-audit for minimal duplication
//! - Automatic cleanup via TestRelay fixture (removes container and temp dirs)
//!
//! # Running Tests
//!
//! ```bash
//! # Run all repository creation tests
//! cargo test --test repository_creation
//!
//! # Run specific test
//! cargo test --test repository_creation test_bare_repo_created_on_announcement
//!
//! # With output
//! cargo test --test repository_creation -- --nocapture
//! ```
mod common;
use common::TestRelay;
use grasp_audit::*;
use grasp_audit::specs::grasp01::RepositoryCreationTests;
/// Macro to generate isolated integration tests
///
/// Each test runs with its own fresh relay instance to ensure complete isolation.
/// This eliminates issues with leftover repositories and ensures clean state.
macro_rules! isolated_test {
($test_name:ident) => {
#[tokio::test]
async fn $test_name() {
let relay = TestRelay::start().await;
let config = AuditConfig::ci();
let client = AuditClient::new(relay.url(), config)
.await
.expect("Failed to create audit client");
let result = RepositoryCreationTests::$test_name(&client, relay.git_data_dir()).await;
relay.stop().await;
assert!(
result.passed,
"{} failed: {}",
stringify!($test_name),
result.error.as_deref().unwrap_or("unknown error")
);
}
};
}
// Generate isolated tests for all repository creation tests
isolated_test!(test_bare_repo_created_on_announcement);
isolated_test!(test_repo_creation_idempotent);
isolated_test!(test_bare_repo_structure);
isolated_test!(test_repo_cleanup);