diff options
| -rw-r--r-- | AGENTS.md | 119 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/explanation/architecture.md | 716 | ||||
| -rw-r--r-- | docs/explanation/inline-authorization.md | 126 | ||||
| -rw-r--r-- | docs/learnings/grasp-audit.md | 209 | ||||
| -rw-r--r-- | docs/reference/test-strategy.md | 1339 |
6 files changed, 552 insertions, 1959 deletions
| @@ -28,95 +28,69 @@ nix-shell | |||
| 28 | nix-shell --run "cargo build" | 28 | nix-shell --run "cargo build" |
| 29 | ``` | 29 | ``` |
| 30 | 30 | ||
| 31 | ### Running Tests | 31 | ### Testing ngit-grasp (Main Project) |
| 32 | 32 | ||
| 33 | **Integration tests require relay running:** | 33 | **ngit-grasp integration tests use the [`TestRelay`](tests/common/relay.rs:14) fixture:** |
| 34 | |||
| 35 | The `TestRelay` fixture automatically starts an instance of ngit-grasp itself and manages its lifecycle: | ||
| 34 | 36 | ||
| 35 | ```bash | 37 | ```bash |
| 36 | # Start ngit-relay first (use any available port to avoid conflicts) | 38 | # Run all ngit-grasp tests (from project root) |
| 37 | docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest | 39 | cargo test |
| 38 | 40 | ||
| 39 | # From grasp-audit directory, set RELAY_URL to match your port | 41 | # Run integration tests only |
| 40 | # Run all ignored tests (includes GRASP-01 and other relay-dependent tests) | 42 | cargo test --test '*' |
| 41 | RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture | ||
| 42 | 43 | ||
| 43 | # Or run a specific test | 44 | # Run specific test file |
| 44 | RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib test_grasp01_nostr_relay_against_relay -- --ignored --nocapture | 45 | cargo test --test nip01_compliance |
| 45 | ``` | 46 | ``` |
| 46 | 47 | ||
| 47 | Tests marked `#[ignore]` need relay - unit tests don't. | 48 | **How TestRelay works:** |
| 48 | |||
| 49 | **Note:** Always use a random available port for the relay to avoid conflicts with existing services. | ||
| 50 | |||
| 51 | ### Standard Testing Process (Recommended) | ||
| 52 | 49 | ||
| 53 | **Use test-ngit-relay.sh for automated relay management:** | 50 | - Spawns `ngit-grasp` binary on a random available port |
| 51 | - Creates temporary directories for git and relay data | ||
| 52 | - Provides `url()` and `domain()` methods for test clients | ||
| 53 | - Automatically cleans up on drop | ||
| 54 | 54 | ||
| 55 | This script handles all relay lifecycle management automatically: | 55 | **Example test pattern:** |
| 56 | - Starts ngit-relay in isolated Docker container | ||
| 57 | - Uses random port to avoid conflicts | ||
| 58 | - Creates isolated temporary directories | ||
| 59 | - Ensures cleanup on exit (success or failure) | ||
| 60 | - Supports both audit and test modes | ||
| 61 | |||
| 62 | **Basic Usage:** | ||
| 63 | |||
| 64 | ```bash | ||
| 65 | # Run cargo test suite (recommended for GRASP-01 development) | ||
| 66 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test | ||
| 67 | 56 | ||
| 68 | # Run audit CLI tool (for quick validation) | 57 | ```rust |
| 69 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh | 58 | use common::TestRelay; |
| 70 | 59 | ||
| 71 | # Get help | 60 | #[tokio::test] |
| 72 | cd grasp-audit && ./test-ngit-relay.sh --help | 61 | async fn test_something() { |
| 62 | let relay = TestRelay::start().await; | ||
| 63 | // relay.url() returns "ws://127.0.0.1:{port}" | ||
| 64 | // ... run test against ngit-grasp ... | ||
| 65 | relay.stop().await; | ||
| 66 | } | ||
| 73 | ``` | 67 | ``` |
| 74 | 68 | ||
| 75 | **Benefits:** | 69 | ### Summary: Which Test Command for What |
| 76 | - No manual relay startup required | ||
| 77 | - Automatic cleanup prevents leftover containers | ||
| 78 | - Random port selection avoids conflicts | ||
| 79 | - Consistent environment across all runs | ||
| 80 | - Proper test isolation | ||
| 81 | 70 | ||
| 82 | **Note:** Manual relay setup is still available but test-ngit-relay.sh is recommended for development workflows. | 71 | | What you're testing | Command | |
| 72 | | --------------------------- | ---------------------------------------------------------------------- | | ||
| 73 | | ngit-grasp (this project) | `cargo test` from project root | | ||
| 74 | | ngit-relay (reference impl) | `cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test` | | ||
| 75 | | grasp-audit unit tests | `cd grasp-audit && nix develop -c cargo test --lib` | | ||
| 83 | 76 | ||
| 84 | ### Running Single Test | 77 | ### Running Single Test |
| 85 | 78 | ||
| 86 | ```bash | 79 | ```bash |
| 87 | # From grasp-audit/ | 80 | # ngit-grasp test (from project root) |
| 88 | nix develop -c cargo test --lib specific_test_name -- --nocapture | 81 | cargo test --test nip01_compliance test_websocket_connection -- --nocapture |
| 89 | ``` | ||
| 90 | |||
| 91 | ### Quick Test Verification | ||
| 92 | |||
| 93 | To verify GRASP-01 compliance tests are working correctly: | ||
| 94 | |||
| 95 | ```bash | ||
| 96 | # Run all ignored library tests (includes GRASP-01) | ||
| 97 | cd grasp-audit && RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture 2>&1 | tail -60 | ||
| 98 | 82 | ||
| 99 | # Or run specific GRASP-01 test | 83 | # grasp-audit test (from grasp-audit/) |
| 100 | cd grasp-audit && RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib test_grasp01_nostr_relay_against_relay -- --ignored --nocapture 2>&1 | tail -60 | 84 | nix develop -c cargo test --lib specific_test_name -- --nocapture |
| 101 | ``` | 85 | ``` |
| 102 | 86 | ||
| 103 | **Expected Output:** | ||
| 104 | - 2-3 tests passing | ||
| 105 | - 15+ tests showing "Not implemented yet" | ||
| 106 | |||
| 107 | ### Troubleshooting | 87 | ### Troubleshooting |
| 108 | 88 | ||
| 109 | **Buffer Size Errors:** | 89 | **Buffer Size Errors:** |
| 110 | If you see mpsc channel buffer size panics on first test run, this is usually transient. Simply run the tests again. | 90 | If you see mpsc channel buffer size panics on first test run, this is usually transient. Simply run the tests again. |
| 111 | 91 | ||
| 112 | **Verify Relay is Running:** | ||
| 113 | Check if relay is accessible before running tests: | ||
| 114 | ```bash | ||
| 115 | nak req -l 1 ws://localhost:18081 # Replace port with your chosen port | ||
| 116 | ``` | ||
| 117 | |||
| 118 | **Port Conflicts:** | 92 | **Port Conflicts:** |
| 119 | Always use a random available port to avoid conflicts with existing services. If a port is busy, choose a different one for docker. | 93 | Both `TestRelay` and `test-ngit-relay.sh` use random ports to avoid conflicts. If you see port errors, ensure no stale processes are running. |
| 120 | 94 | ||
| 121 | ## Code Patterns | 95 | ## Code Patterns |
| 122 | 96 | ||
| @@ -156,8 +130,6 @@ EventBuilder::new(kind, content, &[tags]) | |||
| 156 | EventBuilder::new(kind, content).tags(tags) | 130 | EventBuilder::new(kind, content).tags(tags) |
| 157 | ``` | 131 | ``` |
| 158 | 132 | ||
| 159 | See `docs/archive/2025-11-04-nostr-sdk-upgrade.md` for full migration. | ||
| 160 | |||
| 161 | ### Audit Event Tagging (grasp-audit) | 133 | ### Audit Event Tagging (grasp-audit) |
| 162 | 134 | ||
| 163 | **All audit events automatically include cleanup tags:** | 135 | **All audit events automatically include cleanup tags:** |
| @@ -218,10 +190,10 @@ fn test_audit_tags_automatically_added() { | |||
| 218 | 1. **Workspace compilation:** Can't `cargo build` from root for grasp-audit | 190 | 1. **Workspace compilation:** Can't `cargo build` from root for grasp-audit |
| 219 | 2. **Nix environment:** Must use `nix develop`, not `nix-shell` | 191 | 2. **Nix environment:** Must use `nix develop`, not `nix-shell` |
| 220 | 3. **nostr-sdk API:** Fields not methods in 0.43 | 192 | 3. **nostr-sdk API:** Fields not methods in 0.43 |
| 221 | 4. **Test isolation:** Integration tests need relay, marked with `#[ignore]` | 193 | 4. **Test isolation:** Integration tests use `TestRelay` (ngit-grasp) or `test-ngit-relay.sh` (ngit-relay) |
| 222 | 5. **Work directory:** All session docs go in `work/`, NOT root | 194 | 5. **Work directory:** All session docs go in `work/`, NOT root |
| 223 | 6. **Archive naming:** Use `YYYY-MM-DD-description.md` format | 195 | 6. **Archive naming:** Use `YYYY-MM-DD-description.md` format |
| 224 | 7. **Use test-ngit-relay.sh**: Always use the test script for GRASP-01 tests - it handles cleanup and port management automatically | 196 | 7. **test-ngit-relay.sh tests ngit-relay**: This script tests the reference implementation, NOT ngit-grasp |
| 225 | 197 | ||
| 226 | ## File Restrictions by Mode | 198 | ## File Restrictions by Mode |
| 227 | 199 | ||
| @@ -234,19 +206,14 @@ Code mode can only edit files matching specific patterns (enforced by system): | |||
| 234 | ## Quick Reference | 206 | ## Quick Reference |
| 235 | 207 | ||
| 236 | ```bash | 208 | ```bash |
| 237 | # Recommended: Use test-ngit-relay.sh for all testing | 209 | # Test ngit-grasp (main project) |
| 238 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test | 210 | cargo test |
| 239 | 211 | ||
| 240 | # Build grasp-audit | 212 | # Build grasp-audit |
| 241 | cd grasp-audit && nix develop -c cargo build | 213 | cd grasp-audit && nix develop -c cargo build |
| 242 | 214 | ||
| 243 | # Manual relay testing (if needed) | 215 | # Run grasp-audit unit tests |
| 244 | # 1. Start relay: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest | 216 | cd grasp-audit && nix develop -c cargo test --lib |
| 245 | # 2. Run all ignored tests: RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture | ||
| 246 | # 3. Or specific test: RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib test_grasp01_nostr_relay_against_relay -- --ignored --nocapture | ||
| 247 | |||
| 248 | # Run single test | ||
| 249 | cd grasp-audit && nix develop -c cargo test --lib test_name -- --nocapture | ||
| 250 | 217 | ||
| 251 | # Check session files | 218 | # Check session files |
| 252 | ls work/ # Should only have README.md when clean | 219 | ls work/ # Should only have README.md when clean |
| @@ -14,8 +14,6 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15 | |||
| 14 | 14 | ||
| 15 | ## Status | 15 | ## Status |
| 16 | 16 | ||
| 17 | **ALPHA** - Under active development. API and architecture subject to change. | ||
| 18 | |||
| 19 | ## Key Features | 17 | ## Key Features |
| 20 | 18 | ||
| 21 | - **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself | 19 | - **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself |
diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index ebf7a74..3fb895c 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md | |||
| @@ -2,16 +2,16 @@ | |||
| 2 | 2 | ||
| 3 | ## Executive Summary | 3 | ## Executive Summary |
| 4 | 4 | ||
| 5 | `ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that the `git-http-backend` Rust crate provides sufficient flexibility to intercept and validate Git push operations before they reach the Git repository, eliminating the need for pre-receive hooks. | 5 | `ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that we can intercept and validate Git push operations at the HTTP handler level before reaching the Git repository, eliminating the need for pre-receive hooks. |
| 6 | 6 | ||
| 7 | ## Architectural Decision: Inline vs. Hook-Based Authorization | 7 | ## Architectural Decision: Inline vs. Hook-Based Authorization |
| 8 | 8 | ||
| 9 | ### Investigation Summary | 9 | ### Investigation Summary |
| 10 | 10 | ||
| 11 | After examining both the reference implementation and the `git-http-backend` Rust crate, we have two options: | 11 | After examining both the reference implementation and HTTP server options, we have two options: |
| 12 | 12 | ||
| 13 | #### Option 1: Hook-Based (Reference Implementation Approach) | 13 | #### Option 1: Hook-Based (Reference Implementation Approach) |
| 14 | - Use `git-http-backend` crate as-is | 14 | - Use standard Git HTTP backend |
| 15 | - Create pre-receive and post-receive hooks | 15 | - Create pre-receive and post-receive hooks |
| 16 | - Hooks query the Nostr relay and validate pushes | 16 | - Hooks query the Nostr relay and validate pushes |
| 17 | - **Pros**: Follows reference implementation closely | 17 | - **Pros**: Follows reference implementation closely |
| @@ -28,7 +28,7 @@ After examining both the reference implementation and the `git-http-backend` Rus | |||
| 28 | 28 | ||
| 29 | **Rationale:** | 29 | **Rationale:** |
| 30 | 30 | ||
| 31 | 1. **The `git-http-backend` crate is sufficiently flexible**: Examining `src/actix/git_receive_pack.rs` shows it spawns `git receive-pack` as a subprocess and streams data. We can intercept this. | 31 | 1. **Full control over HTTP layer**: Using Hyper directly gives us complete control over request handling, WebSocket upgrades, and CORS headers. |
| 32 | 32 | ||
| 33 | 2. **Better Developer Experience**: | 33 | 2. **Better Developer Experience**: |
| 34 | - Validation errors can be returned as proper HTTP responses | 34 | - Validation errors can be returned as proper HTTP responses |
| @@ -56,7 +56,7 @@ After examining both the reference implementation and the `git-http-backend` Rus | |||
| 56 | │ │ | 56 | │ │ |
| 57 | │ ┌──────────────────┐ ┌──────────────────┐ │ | 57 | │ ┌──────────────────┐ ┌──────────────────┐ │ |
| 58 | │ │ HTTP Router │ │ Nostr Relay │ │ | 58 | │ │ HTTP Router │ │ Nostr Relay │ │ |
| 59 | │ │ (actix-web) │ │ (nostr-relay- │ │ | 59 | │ │ (Hyper) │ │ (nostr-relay- │ │ |
| 60 | │ │ │ │ builder) │ │ | 60 | │ │ │ │ builder) │ │ |
| 61 | │ └────────┬─────────┘ └────────┬─────────┘ │ | 61 | │ └────────┬─────────┘ └────────┬─────────┘ │ |
| 62 | │ │ │ │ | 62 | │ │ │ │ |
| @@ -90,299 +90,149 @@ After examining both the reference implementation and the `git-http-backend` Rus | |||
| 90 | 90 | ||
| 91 | ## Component Design | 91 | ## Component Design |
| 92 | 92 | ||
| 93 | ### 1. Main Server (`src/main.rs`) | 93 | ### 1. Main Server ([`src/main.rs`](src/main.rs)) |
| 94 | 94 | ||
| 95 | **Responsibilities:** | 95 | **Responsibilities:** |
| 96 | - Initialize configuration from environment | 96 | - Initialize configuration from environment (clap + dotenvy) |
| 97 | - Set up actix-web HTTP server | 97 | - Set up Hyper HTTP server with request routing |
| 98 | - Initialize Nostr relay builder | 98 | - Initialize Nostr relay builder with custom [`Nip34WritePolicy`](src/nostr/builder.rs:51) |
| 99 | - Set up shared storage | 99 | - Set up shared storage (LMDB, NostrDB, or Memory) |
| 100 | - Configure routes for both Git and Nostr endpoints | 100 | - Handle WebSocket upgrades for Nostr relay |
| 101 | - Handle graceful shutdown | 101 | - Handle graceful shutdown |
| 102 | 102 | ||
| 103 | **Key Dependencies:** | 103 | **Key Dependencies:** |
| 104 | ```rust | 104 | ```rust |
| 105 | actix-web = "4" | 105 | hyper = "1" |
| 106 | tokio = { version = "1", features = ["full"] } | 106 | tokio = { version = "1", features = ["full"] } |
| 107 | nostr-relay-builder = "0.43" | 107 | nostr-relay-builder = "0.43" |
| 108 | nostr-sdk = "0.43" | 108 | nostr-sdk = "0.43" |
| 109 | nostr-lmdb = "0.43" | ||
| 109 | ``` | 110 | ``` |
| 110 | 111 | ||
| 111 | ### 2. Git Module (`src/git/`) | 112 | ### 2. HTTP Module ([`src/http/mod.rs`](src/http/mod.rs)) |
| 112 | 113 | ||
| 113 | #### `handler.rs` - Git HTTP Handlers | 114 | **Responsibilities:** |
| 115 | - Route HTTP requests to appropriate handlers | ||
| 116 | - WebSocket upgrade for Nostr relay at `/` | ||
| 117 | - Git Smart HTTP endpoints at `/<npub>/<identifier>.git/*` | ||
| 118 | - Landing pages and NIP-11 document serving | ||
| 119 | - CORS headers on all responses (GRASP-01 requirement) | ||
| 114 | 120 | ||
| 115 | Implements actix-web handlers for Git Smart HTTP protocol: | 121 | **Key Implementation Details:** |
| 116 | 122 | ||
| 117 | ```rust | 123 | ```rust |
| 118 | // GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack | 124 | // CORS headers required by GRASP-01 specification |
| 119 | async fn info_refs_upload_pack( | 125 | const CORS_ALLOW_ORIGIN: &str = "*"; |
| 120 | req: HttpRequest, | 126 | const CORS_ALLOW_METHODS: &str = "GET, POST"; |
| 121 | state: web::Data<AppState>, | 127 | const CORS_ALLOW_HEADERS: &str = "Content-Type"; |
| 122 | ) -> Result<HttpResponse> | 128 | |
| 123 | 129 | /// Add CORS headers to a response builder | |
| 124 | // POST /<npub>/<identifier>.git/git-upload-pack | 130 | fn add_cors_headers(builder: http::response::Builder) -> http::response::Builder { |
| 125 | async fn git_upload_pack( | 131 | builder |
| 126 | req: HttpRequest, | 132 | .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN) |
| 127 | body: web::Payload, | 133 | .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS) |
| 128 | state: web::Data<AppState>, | 134 | .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS) |
| 129 | ) -> Result<HttpResponse> | 135 | } |
| 130 | |||
| 131 | // GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack | ||
| 132 | async fn info_refs_receive_pack( | ||
| 133 | req: HttpRequest, | ||
| 134 | state: web::Data<AppState>, | ||
| 135 | ) -> Result<HttpResponse> | ||
| 136 | |||
| 137 | // POST /<npub>/<identifier>.git/git-receive-pack | ||
| 138 | // THIS IS WHERE THE MAGIC HAPPENS | ||
| 139 | async fn git_receive_pack( | ||
| 140 | req: HttpRequest, | ||
| 141 | body: web::Payload, | ||
| 142 | state: web::Data<AppState>, | ||
| 143 | ) -> Result<HttpResponse> | ||
| 144 | ``` | 136 | ``` |
| 145 | 137 | ||
| 146 | #### `authorization.rs` - Push Validation | 138 | See [`src/http/mod.rs:29-84`](src/http/mod.rs:29-84) for the full CORS implementation. |
| 147 | 139 | ||
| 148 | **Core Logic:** | 140 | ### 3. Git Module ([`src/git/`](src/git/)) |
| 149 | 141 | ||
| 150 | ```rust | 142 | #### [`handlers.rs`](src/git/handlers.rs) - Git HTTP Handlers |
| 151 | pub struct PushValidator { | ||
| 152 | nostr_client: Arc<Client>, | ||
| 153 | relay_url: String, | ||
| 154 | } | ||
| 155 | 143 | ||
| 156 | impl PushValidator { | 144 | Implements handlers for Git Smart HTTP protocol: |
| 157 | /// Validate a push operation against Nostr state | ||
| 158 | pub async fn validate_push( | ||
| 159 | &self, | ||
| 160 | npub: &str, | ||
| 161 | identifier: &str, | ||
| 162 | ref_updates: Vec<RefUpdate>, | ||
| 163 | ) -> Result<ValidationResult> { | ||
| 164 | // 1. Fetch announcement and state events from local relay | ||
| 165 | let events = self.fetch_events(identifier).await?; | ||
| 166 | |||
| 167 | // 2. Extract pubkey from npub | ||
| 168 | let pubkey = decode_npub(npub)?; | ||
| 169 | |||
| 170 | // 3. Get recursive maintainer set | ||
| 171 | let maintainers = get_maintainers(&events, &pubkey, identifier); | ||
| 172 | |||
| 173 | // 4. Get latest state from maintainers | ||
| 174 | let state = get_state_from_maintainers(&events, &maintainers)?; | ||
| 175 | |||
| 176 | // 5. Validate each ref update | ||
| 177 | for ref_update in ref_updates { | ||
| 178 | if ref_update.ref_name.starts_with("refs/nostr/") { | ||
| 179 | // Allow refs/nostr/<event-id> for PRs | ||
| 180 | validate_pr_ref(&ref_update)?; | ||
| 181 | } else if ref_update.ref_name.starts_with("refs/heads/pr/") { | ||
| 182 | // Reject pr/* branches - should use refs/nostr/ | ||
| 183 | return Err(Error::InvalidRef("pr/* branches must use refs/nostr/")); | ||
| 184 | } else { | ||
| 185 | // Validate against state event | ||
| 186 | validate_state_ref(&state, &ref_update)?; | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | Ok(ValidationResult::Accept) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | ``` | ||
| 194 | |||
| 195 | **Key Functions:** | ||
| 196 | 145 | ||
| 197 | ```rust | 146 | ```rust |
| 198 | /// Parse ref updates from git-receive-pack request body | 147 | /// Handle GET /info/refs?service=git-{upload,receive}-pack |
| 199 | fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> | 148 | pub async fn handle_info_refs( |
| 200 | 149 | repo_path: PathBuf, | |
| 201 | /// Recursively find all maintainers | 150 | service: GitService, |
| 202 | fn get_maintainers( | 151 | ) -> Result<Response<Full<Bytes>>, GitError> |
| 203 | events: &[Event], | 152 | |
| 204 | pubkey: &str, | 153 | /// Handle POST /git-upload-pack (clone/fetch) |
| 154 | pub async fn handle_upload_pack( | ||
| 155 | repo_path: PathBuf, | ||
| 156 | body: Bytes, | ||
| 157 | ) -> Result<Response<Full<Bytes>>, GitError> | ||
| 158 | |||
| 159 | /// Handle POST /git-receive-pack (push) | ||
| 160 | /// THIS IS WHERE THE MAGIC HAPPENS - validates against state before accepting | ||
| 161 | pub async fn handle_receive_pack( | ||
| 162 | repo_path: PathBuf, | ||
| 163 | body: Bytes, | ||
| 164 | database: SharedDatabase, | ||
| 165 | npub: &str, | ||
| 205 | identifier: &str, | 166 | identifier: &str, |
| 206 | ) -> Vec<String> | 167 | ) -> Result<Response<Full<Bytes>>, GitError> |
| 207 | |||
| 208 | /// Get latest state from maintainer set | ||
| 209 | fn get_state_from_maintainers( | ||
| 210 | events: &[Event], | ||
| 211 | maintainers: &[String], | ||
| 212 | ) -> Result<RepositoryState> | ||
| 213 | |||
| 214 | /// Validate a ref matches the state event | ||
| 215 | fn validate_state_ref( | ||
| 216 | state: &RepositoryState, | ||
| 217 | ref_update: &RefUpdate, | ||
| 218 | ) -> Result<()> | ||
| 219 | ``` | 168 | ``` |
| 220 | 169 | ||
| 221 | ### 3. Nostr Module (`src/nostr/`) | 170 | See [`src/git/handlers.rs:22-98`](src/git/handlers.rs:22-98) for the info-refs implementation. |
| 222 | 171 | ||
| 223 | #### `relay.rs` - Relay Configuration | 172 | #### [`authorization.rs`](src/git/authorization.rs) - Push Validation |
| 224 | 173 | ||
| 225 | ```rust | 174 | **Core Logic:** |
| 226 | pub async fn build_relay(config: &Config) -> Result<LocalRelay> { | ||
| 227 | let builder = RelayBuilder::default() | ||
| 228 | .write_policy(RepositoryAnnouncementPolicy::new(config.domain.clone())) | ||
| 229 | .write_policy(RelatedEventsPolicy::new()) | ||
| 230 | .query_policy(StandardQueryPolicy::new()) | ||
| 231 | .on_event_saved(create_repository_hook(config.git_data_path.clone())); | ||
| 232 | |||
| 233 | // Configure storage backend (LMDB or NDB) | ||
| 234 | let relay = LocalRelay::run(builder).await?; | ||
| 235 | |||
| 236 | Ok(relay) | ||
| 237 | } | ||
| 238 | ``` | ||
| 239 | |||
| 240 | #### `events.rs` - Event Handlers | ||
| 241 | 175 | ||
| 242 | ```rust | 176 | ```rust |
| 243 | /// Hook called when events are saved | 177 | /// Get authorization info for a repository owner |
| 244 | pub fn create_repository_hook( | 178 | pub async fn get_authorization_for_owner( |
| 245 | git_data_path: PathBuf, | 179 | database: &SharedDatabase, |
| 246 | ) -> impl Fn(&Event) -> BoxFuture<'static, ()> { | 180 | pubkey: &PublicKey, |
| 247 | move |event: &Event| { | 181 | identifier: &str, |
| 248 | let git_path = git_data_path.clone(); | 182 | ) -> Result<AuthorizationResult, AuthorizationError> |
| 249 | Box::pin(async move { | ||
| 250 | if event.kind == Kind::RepositoryAnnouncement { | ||
| 251 | handle_repository_announcement(event, &git_path).await; | ||
| 252 | } else if event.kind == Kind::RepositoryState { | ||
| 253 | handle_repository_state(event, &git_path).await; | ||
| 254 | } | ||
| 255 | }) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | 183 | ||
| 259 | async fn handle_repository_announcement(event: &Event, git_path: &Path) { | 184 | /// Validate that pushed refs match the authorized state |
| 260 | // 1. Parse repository from event | 185 | pub fn validate_push_refs( |
| 261 | // 2. Check if listed in clone and relays tags | 186 | pushed_refs: &[PushedRef], |
| 262 | // 3. Create empty bare Git repository | 187 | state: &RepositoryState, |
| 263 | // 4. Configure uploadpack.allowTipSHA1InWant | 188 | ) -> Result<(), AuthorizationError> |
| 264 | // 5. Configure uploadpack.allowUnreachable | ||
| 265 | // 6. Configure http.receivepack | ||
| 266 | } | ||
| 267 | 189 | ||
| 268 | async fn handle_repository_state(event: &Event, git_path: &Path) { | 190 | /// Validate refs/nostr/<event-id> pushes |
| 269 | // 1. Parse state from event | 191 | pub fn validate_nostr_ref_pushes( |
| 270 | // 2. Update repository HEAD if needed | 192 | pushed_refs: &[PushedRef], |
| 271 | // 3. Trigger proactive sync (GRASP-02) | 193 | database: &SharedDatabase, |
| 272 | } | 194 | ) -> Result<(), AuthorizationError> |
| 273 | ``` | 195 | ``` |
| 274 | 196 | ||
| 275 | **Write Policies:** | 197 | ### 4. Nostr Module ([`src/nostr/`](src/nostr/)) |
| 276 | 198 | ||
| 277 | ```rust | 199 | #### [`builder.rs`](src/nostr/builder.rs) - Relay Configuration |
| 278 | /// Accept repository announcements that list this instance | ||
| 279 | pub struct RepositoryAnnouncementPolicy { | ||
| 280 | domain: String, | ||
| 281 | } | ||
| 282 | |||
| 283 | impl WritePolicy for RepositoryAnnouncementPolicy { | ||
| 284 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 285 | -> BoxFuture<PolicyResult> | ||
| 286 | { | ||
| 287 | Box::pin(async move { | ||
| 288 | if event.kind != Kind::RepositoryAnnouncement { | ||
| 289 | return PolicyResult::Accept; // Not our concern | ||
| 290 | } | ||
| 291 | |||
| 292 | // Check if this instance is in clone and relays tags | ||
| 293 | let has_clone = event.tags.iter() | ||
| 294 | .any(|t| t.kind() == "clone" && t.content() == Some(&self.domain)); | ||
| 295 | let has_relay = event.tags.iter() | ||
| 296 | .any(|t| t.kind() == "relays" && t.content() == Some(&self.domain)); | ||
| 297 | |||
| 298 | if has_clone && has_relay { | ||
| 299 | PolicyResult::Accept | ||
| 300 | } else { | ||
| 301 | PolicyResult::Reject("instance not listed in clone and relays".into()) | ||
| 302 | } | ||
| 303 | }) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | 200 | ||
| 307 | /// Accept events related to stored announcements/issues/patches | 201 | The [`Nip34WritePolicy`](src/nostr/builder.rs:51) is the core event validation logic: |
| 308 | pub struct RelatedEventsPolicy; | ||
| 309 | 202 | ||
| 310 | impl WritePolicy for RelatedEventsPolicy { | 203 | ```rust |
| 311 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | 204 | /// NIP-34 Write Policy with Full GRASP-01 Event Validation |
| 312 | -> BoxFuture<PolicyResult> | 205 | /// |
| 313 | { | 206 | /// Validates all events according to GRASP-01 specification: |
| 314 | // Accept if event tags or is tagged by stored events | 207 | /// - Repository announcements must list service in clone and relays tags |
| 315 | // Implementation requires querying the event store | 208 | /// EXCEPTION: Recursive maintainer announcements are accepted even without |
| 316 | } | 209 | /// listing the service, to enable maintainer chain discovery and GRASP-02 sync |
| 210 | /// - Repository state announcements must have valid structure | ||
| 211 | /// - Other events must reference accepted repositories or events | ||
| 212 | /// - Forward references are supported (events referenced by accepted events) | ||
| 213 | /// - Orphan events with no valid references are rejected | ||
| 214 | pub struct Nip34WritePolicy { | ||
| 215 | domain: String, | ||
| 216 | database: SharedDatabase, | ||
| 217 | git_data_path: PathBuf, | ||
| 317 | } | 218 | } |
| 318 | ``` | 219 | ``` |
| 319 | 220 | ||
| 320 | ### 4. Storage Module (`src/storage/`) | 221 | See [`src/nostr/builder.rs:38-78`](src/nostr/builder.rs:38-78) for the full policy struct. |
| 321 | 222 | ||
| 322 | #### `repository.rs` - Repository Management | 223 | #### [`events.rs`](src/nostr/events.rs) - Event Parsing |
| 224 | |||
| 225 | Provides structures for parsing NIP-34 events: | ||
| 323 | 226 | ||
| 324 | ```rust | 227 | ```rust |
| 325 | pub struct RepositoryManager { | 228 | /// Parsed repository announcement (Kind 30617) |
| 326 | git_data_path: PathBuf, | 229 | pub struct RepositoryAnnouncement { ... } |
| 327 | } | ||
| 328 | 230 | ||
| 329 | impl RepositoryManager { | 231 | /// Parsed repository state (Kind 30618) |
| 330 | /// Create a new bare Git repository | 232 | pub struct RepositoryState { ... } |
| 331 | pub async fn create_repository( | ||
| 332 | &self, | ||
| 333 | npub: &str, | ||
| 334 | identifier: &str, | ||
| 335 | ) -> Result<PathBuf> { | ||
| 336 | let repo_path = self.git_data_path | ||
| 337 | .join(npub) | ||
| 338 | .join(format!("{}.git", identifier)); | ||
| 339 | |||
| 340 | // Create directory | ||
| 341 | tokio::fs::create_dir_all(&repo_path).await?; | ||
| 342 | |||
| 343 | // Initialize bare repo | ||
| 344 | Command::new("git") | ||
| 345 | .args(&["init", "--bare"]) | ||
| 346 | .arg(&repo_path) | ||
| 347 | .output() | ||
| 348 | .await?; | ||
| 349 | |||
| 350 | // Configure | ||
| 351 | self.configure_repository(&repo_path).await?; | ||
| 352 | |||
| 353 | Ok(repo_path) | ||
| 354 | } | ||
| 355 | |||
| 356 | async fn configure_repository(&self, repo_path: &Path) -> Result<()> { | ||
| 357 | // Enable unauthenticated push (we handle auth ourselves) | ||
| 358 | git_config(repo_path, "http.receivepack", "true").await?; | ||
| 359 | |||
| 360 | // Enable tip SHA1 fetching (required for ngit) | ||
| 361 | git_config(repo_path, "uploadpack.allowTipSHA1InWant", "true").await?; | ||
| 362 | |||
| 363 | // Enable unreachable object fetching | ||
| 364 | git_config(repo_path, "uploadpack.allowUnreachable", "true").await?; | ||
| 365 | |||
| 366 | Ok(()) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Check if repository exists | ||
| 370 | pub async fn repository_exists( | ||
| 371 | &self, | ||
| 372 | npub: &str, | ||
| 373 | identifier: &str, | ||
| 374 | ) -> bool { | ||
| 375 | let repo_path = self.git_data_path | ||
| 376 | .join(npub) | ||
| 377 | .join(format!("{}.git", identifier)); | ||
| 378 | |||
| 379 | repo_path.join("HEAD").exists() && | ||
| 380 | repo_path.join("config").exists() | ||
| 381 | } | ||
| 382 | } | ||
| 383 | ``` | 233 | ``` |
| 384 | 234 | ||
| 385 | ### 5. Configuration (`src/config.rs`) | 235 | ### 5. Configuration ([`src/config.rs`](src/config.rs)) |
| 386 | 236 | ||
| 387 | ```rust | 237 | ```rust |
| 388 | pub struct Config { | 238 | pub struct Config { |
| @@ -393,34 +243,18 @@ pub struct Config { | |||
| 393 | pub git_data_path: PathBuf, | 243 | pub git_data_path: PathBuf, |
| 394 | pub relay_data_path: PathBuf, | 244 | pub relay_data_path: PathBuf, |
| 395 | pub bind_address: SocketAddr, | 245 | pub bind_address: SocketAddr, |
| 396 | pub log_level: String, | 246 | pub database_backend: DatabaseBackend, |
| 397 | } | 247 | } |
| 398 | 248 | ||
| 399 | impl Config { | 249 | pub enum DatabaseBackend { |
| 400 | pub fn from_env() -> Result<Self> { | 250 | Lmdb, // Default, production use |
| 401 | Ok(Config { | 251 | NostrDb, // Alternative |
| 402 | domain: env::var("NGIT_DOMAIN")?, | 252 | Memory, // Testing |
| 403 | owner_npub: env::var("NGIT_OWNER_NPUB")?, | ||
| 404 | relay_name: env::var("NGIT_RELAY_NAME")?, | ||
| 405 | relay_description: env::var("NGIT_RELAY_DESCRIPTION")?, | ||
| 406 | git_data_path: PathBuf::from( | ||
| 407 | env::var("NGIT_GIT_DATA_PATH") | ||
| 408 | .unwrap_or_else(|_| "./data/git".to_string()) | ||
| 409 | ), | ||
| 410 | relay_data_path: PathBuf::from( | ||
| 411 | env::var("NGIT_RELAY_DATA_PATH") | ||
| 412 | .unwrap_or_else(|_| "./data/relay".to_string()) | ||
| 413 | ), | ||
| 414 | bind_address: env::var("NGIT_BIND_ADDRESS") | ||
| 415 | .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) | ||
| 416 | .parse()?, | ||
| 417 | log_level: env::var("RUST_LOG") | ||
| 418 | .unwrap_or_else(|_| "info".to_string()), | ||
| 419 | }) | ||
| 420 | } | ||
| 421 | } | 253 | } |
| 422 | ``` | 254 | ``` |
| 423 | 255 | ||
| 256 | Configuration is loaded via **clap CLI > environment variables > .env > defaults**. | ||
| 257 | |||
| 424 | ## Data Flow | 258 | ## Data Flow |
| 425 | 259 | ||
| 426 | ### Push Operation Flow | 260 | ### Push Operation Flow |
| @@ -428,327 +262,120 @@ impl Config { | |||
| 428 | ``` | 262 | ``` |
| 429 | 1. Git Client → POST /<npub>/<id>.git/git-receive-pack | 263 | 1. Git Client → POST /<npub>/<id>.git/git-receive-pack |
| 430 | ↓ | 264 | ↓ |
| 431 | 2. git_receive_pack handler receives request | 265 | 2. HttpService routes to git::handlers::handle_receive_pack() |
| 432 | ↓ | 266 | ↓ |
| 433 | 3. Parse ref updates from request body | 267 | 3. Parse ref updates from request body (pkt-line format) |
| 434 | ↓ | 268 | ↓ |
| 435 | 4. Extract npub and identifier from URL | 269 | 4. Extract npub and identifier from URL |
| 436 | ↓ | 270 | ↓ |
| 437 | 5. PushValidator::validate_push() | 271 | 5. authorization::get_authorization_for_owner() |
| 438 | ├─ Fetch events from local Nostr relay | 272 | ├─ Query database for announcements |
| 439 | ├─ Get maintainers recursively | 273 | ├─ Build recursive maintainer set |
| 440 | ├─ Get latest state from maintainers | 274 | └─ Get latest authorized state |
| 441 | └─ Validate each ref update | 275 | ↓ |
| 276 | 6. authorization::validate_push_refs() | ||
| 277 | ├─ Check each ref matches state | ||
| 278 | └─ Validate refs/nostr/ pushes | ||
| 442 | ↓ | 279 | ↓ |
| 443 | 6. If VALID: | 280 | 7. If VALID: |
| 444 | ├─ Spawn git-receive-pack subprocess | 281 | ├─ Spawn git-receive-pack subprocess |
| 445 | ├─ Stream request body to git stdin | 282 | ├─ Stream request body to git stdin |
| 446 | └─ Stream git stdout back to client | 283 | └─ Stream git stdout back to client |
| 447 | ↓ | 284 | ↓ |
| 448 | 7. If INVALID: | 285 | 8. If INVALID: |
| 449 | └─ Return HTTP 403 with error message | 286 | └─ Return HTTP 403 with error message |
| 450 | ``` | 287 | ``` |
| 451 | 288 | ||
| 452 | ### Repository Announcement Flow | 289 | ### Repository Announcement Flow |
| 453 | 290 | ||
| 454 | ``` | 291 | ``` |
| 455 | 1. Nostr Client → EVENT (Kind 30317) | 292 | 1. Nostr Client → EVENT (Kind 30617) |
| 456 | ↓ | 293 | ↓ |
| 457 | 2. Nostr relay receives event | 294 | 2. Nostr relay receives event |
| 458 | ↓ | 295 | ↓ |
| 459 | 3. RepositoryAnnouncementPolicy::admit_event() | 296 | 3. Nip34WritePolicy::admit_event() |
| 460 | ├─ Check if instance in clone tags | 297 | ├─ Check if instance in clone tags |
| 461 | ├─ Check if instance in relays tags | 298 | ├─ Check if instance in relays tags |
| 299 | ├─ OR: Check if recursive maintainer | ||
| 462 | └─ Accept or reject | 300 | └─ Accept or reject |
| 463 | ↓ | 301 | ↓ |
| 464 | 4. If ACCEPTED: | 302 | 4. If ACCEPTED: |
| 465 | ├─ Event saved to store | 303 | ├─ Event saved to database |
| 466 | └─ on_event_saved hook triggered | 304 | └─ ensure_bare_repository() called |
| 467 | ↓ | 305 | ↓ |
| 468 | 5. handle_repository_announcement() | 306 | 5. Bare Git repository created at |
| 469 | ├─ Parse repository details | 307 | <git_data_path>/<npub>/<identifier>.git |
| 470 | ├─ Create Git repository directory | ||
| 471 | ├─ Initialize bare Git repo | ||
| 472 | └─ Configure Git settings | ||
| 473 | ``` | ||
| 474 | |||
| 475 | ## Key Implementation Details | ||
| 476 | |||
| 477 | ### 1. Parsing Git Receive-Pack Protocol | ||
| 478 | |||
| 479 | The Git receive-pack protocol uses a pkt-line format. We need to parse: | ||
| 480 | |||
| 481 | ``` | ||
| 482 | 0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/main\0 report-status | ||
| 483 | 0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/dev | ||
| 484 | ``` | ||
| 485 | |||
| 486 | Each line has: | ||
| 487 | - Old SHA (40 hex chars) | ||
| 488 | - Space | ||
| 489 | - New SHA (40 hex chars) | ||
| 490 | - Space | ||
| 491 | - Ref name | ||
| 492 | - Optional capabilities (first line only, after \0) | ||
| 493 | |||
| 494 | ```rust | ||
| 495 | pub struct RefUpdate { | ||
| 496 | pub old_sha: String, | ||
| 497 | pub new_sha: String, | ||
| 498 | pub ref_name: String, | ||
| 499 | } | ||
| 500 | |||
| 501 | pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> { | ||
| 502 | // Parse pkt-line format | ||
| 503 | // Extract ref updates | ||
| 504 | // Return structured data | ||
| 505 | } | ||
| 506 | ``` | 308 | ``` |
| 507 | 309 | ||
| 508 | ### 2. Maintainer Recursion | 310 | ### State Event Flow |
| 509 | |||
| 510 | The maintainer resolution must handle cycles and correctly build the set: | ||
| 511 | 311 | ||
| 512 | ```rust | ||
| 513 | fn get_maintainers_recursive( | ||
| 514 | events: &[Event], | ||
| 515 | pubkey: &str, | ||
| 516 | identifier: &str, | ||
| 517 | visited: &mut HashSet<String>, | ||
| 518 | ) -> HashSet<String> { | ||
| 519 | if visited.contains(pubkey) { | ||
| 520 | return HashSet::new(); | ||
| 521 | } | ||
| 522 | |||
| 523 | visited.insert(pubkey.to_string()); | ||
| 524 | |||
| 525 | let announcement = find_announcement(events, pubkey, identifier); | ||
| 526 | if announcement.is_none() { | ||
| 527 | return HashSet::new(); | ||
| 528 | } | ||
| 529 | |||
| 530 | let repo = parse_repository(announcement.unwrap()); | ||
| 531 | |||
| 532 | for maintainer in repo.maintainers { | ||
| 533 | get_maintainers_recursive(events, &maintainer, identifier, visited); | ||
| 534 | } | ||
| 535 | |||
| 536 | visited.clone() | ||
| 537 | } | ||
| 538 | ``` | 312 | ``` |
| 539 | 313 | 1. Nostr Client → EVENT (Kind 30618) | |
| 540 | ### 3. State Event Validation | 314 | ↓ |
| 541 | 315 | 2. Nostr relay receives event | |
| 542 | ```rust | 316 | ↓ |
| 543 | fn validate_state_ref( | 317 | 3. Nip34WritePolicy::admit_event() |
| 544 | state: &RepositoryState, | 318 | ├─ Check author is in maintainer set |
| 545 | ref_update: &RefUpdate, | 319 | ├─ Validate state structure |
| 546 | ) -> Result<()> { | 320 | └─ Accept or reject |
| 547 | if ref_update.ref_name.starts_with("refs/heads/") { | 321 | ↓ |
| 548 | let branch_name = &ref_update.ref_name[11..]; | 322 | 4. If ACCEPTED and is latest state: |
| 549 | if let Some(commit) = state.branches.get(branch_name) { | 323 | ├─ Align repository refs to match state |
| 550 | if commit == &ref_update.new_sha { | 324 | ├─ Create/update/delete refs as needed |
| 551 | return Ok(()); | 325 | └─ Set HEAD if commit available |
| 552 | } | ||
| 553 | return Err(Error::StateMismatch { | ||
| 554 | ref_name: ref_update.ref_name.clone(), | ||
| 555 | expected: commit.clone(), | ||
| 556 | got: ref_update.new_sha.clone(), | ||
| 557 | }); | ||
| 558 | } | ||
| 559 | return Err(Error::RefNotInState(ref_update.ref_name.clone())); | ||
| 560 | } | ||
| 561 | |||
| 562 | if ref_update.ref_name.starts_with("refs/tags/") { | ||
| 563 | let tag_name = &ref_update.ref_name[10..]; | ||
| 564 | if let Some(commit) = state.tags.get(tag_name) { | ||
| 565 | if commit == &ref_update.new_sha { | ||
| 566 | return Ok(()); | ||
| 567 | } | ||
| 568 | return Err(Error::StateMismatch { | ||
| 569 | ref_name: ref_update.ref_name.clone(), | ||
| 570 | expected: commit.clone(), | ||
| 571 | got: ref_update.new_sha.clone(), | ||
| 572 | }); | ||
| 573 | } | ||
| 574 | return Err(Error::RefNotInState(ref_update.ref_name.clone())); | ||
| 575 | } | ||
| 576 | |||
| 577 | Err(Error::InvalidRef(ref_update.ref_name.clone())) | ||
| 578 | } | ||
| 579 | ``` | ||
| 580 | |||
| 581 | ### 4. CORS Support | ||
| 582 | |||
| 583 | As per GRASP-01, we must support CORS: | ||
| 584 | |||
| 585 | ```rust | ||
| 586 | use actix_cors::Cors; | ||
| 587 | |||
| 588 | fn configure_cors() -> Cors { | ||
| 589 | Cors::default() | ||
| 590 | .allow_any_origin() | ||
| 591 | .allowed_methods(vec!["GET", "POST", "OPTIONS"]) | ||
| 592 | .allowed_headers(vec!["Content-Type"]) | ||
| 593 | .max_age(3600) | ||
| 594 | } | ||
| 595 | |||
| 596 | // In main.rs | ||
| 597 | App::new() | ||
| 598 | .wrap(configure_cors()) | ||
| 599 | .configure(git_routes) | ||
| 600 | .configure(nostr_routes) | ||
| 601 | ``` | 326 | ``` |
| 602 | 327 | ||
| 603 | ## Testing Strategy | 328 | ## Testing Strategy |
| 604 | 329 | ||
| 605 | See [TEST_STRATEGY.md](TEST_STRATEGY.md) for comprehensive testing documentation, including: | 330 | See [test-strategy.md](../reference/test-strategy.md) for comprehensive testing documentation. |
| 606 | |||
| 607 | - **GRASP Compliance Testing Tool**: Reusable test suite that validates any GRASP implementation against the spec | ||
| 608 | - **Spec-Mirrored Tests**: Test structure matches GRASP protocol documents exactly | ||
| 609 | - **Clear Failure Messages**: Test failures cite exact spec lines (e.g., "GRASP-01:12-13") | ||
| 610 | - **Multiple Test Levels**: Unit, integration, compliance, and end-to-end tests | ||
| 611 | 331 | ||
| 612 | ### Quick Overview | 332 | ### Quick Overview |
| 613 | 333 | ||
| 614 | ```rust | 334 | **Integration Tests** ([`tests/`](tests/)): |
| 615 | // Unit Tests - Individual functions | 335 | - Use [`TestRelay`](tests/common/relay.rs:14) fixture for automatic relay lifecycle |
| 616 | #[test] | 336 | - Each test file in [`tests/`](tests/) covers a GRASP-01 requirement |
| 617 | fn test_parse_ref_updates() { | ||
| 618 | let body = b"0000... 0000... refs/heads/main\0report-status\n"; | ||
| 619 | let updates = parse_ref_updates(body).unwrap(); | ||
| 620 | assert_eq!(updates.len(), 1); | ||
| 621 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 622 | } | ||
| 623 | 337 | ||
| 624 | // Integration Tests - Component interaction | 338 | **Audit Tests** ([`grasp-audit/`](grasp-audit/)): |
| 625 | #[tokio::test] | 339 | - Reusable compliance testing for any GRASP implementation |
| 626 | async fn test_full_push_flow() { | 340 | - Spec-mirrored structure in [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/) |
| 627 | let app = test_app().await; | ||
| 628 | let (announcement, state) = app.create_repo_with_state() | ||
| 629 | .branch("main", "commit-123") | ||
| 630 | .build() | ||
| 631 | .await; | ||
| 632 | |||
| 633 | let result = app.git_push("main", "commit-123").await; | ||
| 634 | assert!(result.success); | ||
| 635 | } | ||
| 636 | 341 | ||
| 637 | // Compliance Tests - GRASP spec validation | 342 | ```rust |
| 343 | // Example: tests/nip01_compliance.rs | ||
| 638 | #[tokio::test] | 344 | #[tokio::test] |
| 639 | async fn test_grasp_01_compliance() { | 345 | async fn test_nip01_websocket_connection() { |
| 640 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | 346 | let relay = TestRelay::start().await; |
| 641 | 347 | // Test NIP-01 compliance... | |
| 642 | let ctx = TestContext::builder() | 348 | relay.stop().await; |
| 643 | .base_url(&server.url()) | ||
| 644 | .build(); | ||
| 645 | |||
| 646 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 647 | assert!(results.all_passed(), "{}", results.report()); | ||
| 648 | } | 349 | } |
| 649 | ``` | 350 | ``` |
| 650 | 351 | ||
| 651 | The compliance testing tool is designed as a **standalone crate** that can be: | ||
| 652 | - Used by ngit-grasp for self-validation | ||
| 653 | - Published for other GRASP implementations to use | ||
| 654 | - Updated as new GRASP specs are released | ||
| 655 | - Run in CI/CD for continuous compliance verification | ||
| 656 | |||
| 657 | ## Performance Considerations | 352 | ## Performance Considerations |
| 658 | 353 | ||
| 659 | ### 1. Async All The Way | 354 | ### 1. Async All The Way |
| 660 | 355 | ||
| 661 | - Use `tokio` for all I/O | 356 | - Use `tokio` for all I/O |
| 662 | - Non-blocking Git subprocess spawning | 357 | - Non-blocking Git subprocess spawning via [`GitSubprocess`](src/git/subprocess.rs) |
| 663 | - Stream large pack files without buffering | 358 | - Stream large pack files without buffering |
| 664 | 359 | ||
| 665 | ### 2. Connection Pooling | 360 | ### 2. Shared Database |
| 666 | |||
| 667 | - Reuse Nostr relay connections | ||
| 668 | - Connection pool for internal relay queries | ||
| 669 | |||
| 670 | ### 3. Caching | ||
| 671 | 361 | ||
| 672 | - Cache parsed state events (with TTL) | 362 | - Single database instance shared between relay and Git handlers |
| 673 | - Cache maintainer sets | 363 | - Direct queries for push authorization (no WebSocket round-trip) |
| 674 | - Invalidate on new state events | ||
| 675 | 364 | ||
| 676 | ```rust | 365 | ### 3. Write Policy Caching |
| 677 | pub struct StateCache { | ||
| 678 | cache: Arc<RwLock<HashMap<String, CachedState>>>, | ||
| 679 | } | ||
| 680 | |||
| 681 | struct CachedState { | ||
| 682 | state: RepositoryState, | ||
| 683 | maintainers: Vec<String>, | ||
| 684 | timestamp: Instant, | ||
| 685 | } | ||
| 686 | 366 | ||
| 687 | impl StateCache { | 367 | - Maintainer sets computed once per event validation |
| 688 | pub async fn get_or_fetch( | 368 | - State lookups use database indexes |
| 689 | &self, | ||
| 690 | identifier: &str, | ||
| 691 | fetcher: impl Future<Output = Result<(RepositoryState, Vec<String>)>>, | ||
| 692 | ) -> Result<(RepositoryState, Vec<String>)> { | ||
| 693 | // Check cache | ||
| 694 | // Return if fresh | ||
| 695 | // Otherwise fetch and cache | ||
| 696 | } | ||
| 697 | } | ||
| 698 | ``` | ||
| 699 | 369 | ||
| 700 | ## Future Extensions | 370 | ## Future Extensions |
| 701 | 371 | ||
| 702 | ### GRASP-02: Proactive Sync | 372 | ### GRASP-02: Proactive Sync |
| 703 | 373 | ||
| 704 | Add background tasks: | 374 | See [grasp-02-proactive-sync.md](grasp-02-proactive-sync.md) for detailed design. |
| 705 | |||
| 706 | ```rust | ||
| 707 | pub struct ProactiveSyncTask { | ||
| 708 | relay_client: Client, | ||
| 709 | git_manager: RepositoryManager, | ||
| 710 | } | ||
| 711 | |||
| 712 | impl ProactiveSyncTask { | ||
| 713 | pub async fn run(&self) { | ||
| 714 | loop { | ||
| 715 | tokio::time::sleep(Duration::from_secs(3600)).await; | ||
| 716 | |||
| 717 | // Fetch all announcements from our relay | ||
| 718 | let announcements = self.fetch_announcements().await; | ||
| 719 | |||
| 720 | for ann in announcements { | ||
| 721 | // Sync events from listed relays | ||
| 722 | self.sync_events(&ann).await; | ||
| 723 | |||
| 724 | // Sync git data from listed clones | ||
| 725 | self.sync_git_data(&ann).await; | ||
| 726 | |||
| 727 | // Fetch PR data | ||
| 728 | self.sync_pr_data(&ann).await; | ||
| 729 | } | ||
| 730 | } | ||
| 731 | } | ||
| 732 | } | ||
| 733 | ``` | ||
| 734 | 375 | ||
| 735 | ### GRASP-05: Archive | 376 | ### GRASP-05: Archive |
| 736 | 377 | ||
| 737 | Relax the policy: | 378 | Relax the write policy to accept all repository announcements regardless of clone/relays tags. |
| 738 | |||
| 739 | ```rust | ||
| 740 | pub struct ArchiveAnnouncementPolicy; | ||
| 741 | |||
| 742 | impl WritePolicy for ArchiveAnnouncementPolicy { | ||
| 743 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 744 | -> BoxFuture<PolicyResult> | ||
| 745 | { | ||
| 746 | // Accept all repository announcements | ||
| 747 | // Don't check clone/relays tags | ||
| 748 | PolicyResult::Accept | ||
| 749 | } | ||
| 750 | } | ||
| 751 | ``` | ||
| 752 | 379 | ||
| 753 | ## Deployment | 380 | ## Deployment |
| 754 | 381 | ||
| @@ -756,7 +383,7 @@ impl WritePolicy for ArchiveAnnouncementPolicy { | |||
| 756 | 383 | ||
| 757 | ```bash | 384 | ```bash |
| 758 | cargo build --release | 385 | cargo build --release |
| 759 | ./target/release/ngit-grasp | 386 | ./target/release/ngit-grasp --domain example.com --owner-npub npub1... |
| 760 | ``` | 387 | ``` |
| 761 | 388 | ||
| 762 | ### Docker | 389 | ### Docker |
| @@ -799,10 +426,17 @@ WantedBy=multi-user.target | |||
| 799 | 2. **Path Traversal**: Prevent directory traversal in repository paths | 426 | 2. **Path Traversal**: Prevent directory traversal in repository paths |
| 800 | 3. **DoS Protection**: Rate limiting on both HTTP and WebSocket | 427 | 3. **DoS Protection**: Rate limiting on both HTTP and WebSocket |
| 801 | 4. **Resource Limits**: Limit pack file sizes, event sizes | 428 | 4. **Resource Limits**: Limit pack file sizes, event sizes |
| 802 | 5. **Nostr Event Validation**: Strict signature verification | 429 | 5. **Nostr Event Validation**: Strict signature verification (handled by nostr-relay-builder) |
| 803 | 430 | ||
| 804 | ## Conclusion | 431 | ## Conclusion |
| 805 | 432 | ||
| 806 | The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. The Rust ecosystem provides excellent libraries for both Git and Nostr protocols, enabling a high-performance, type-safe implementation. | 433 | The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. Using Hyper for the HTTP layer gives us complete control over request handling, WebSocket upgrades, and CORS headers. |
| 807 | 434 | ||
| 808 | The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components. | 435 | The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components. |
| 436 | |||
| 437 | ## Related Documentation | ||
| 438 | |||
| 439 | - [Inline Authorization Explanation](inline-authorization.md) - Why we chose this approach | ||
| 440 | - [GRASP-02 Proactive Sync](grasp-02-proactive-sync.md) - Future work design | ||
| 441 | - [Test Strategy](../reference/test-strategy.md) - Comprehensive testing documentation | ||
| 442 | - [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons learned \ No newline at end of file | ||
diff --git a/docs/explanation/inline-authorization.md b/docs/explanation/inline-authorization.md index 98f6e5a..4538602 100644 --- a/docs/explanation/inline-authorization.md +++ b/docs/explanation/inline-authorization.md | |||
| @@ -150,14 +150,17 @@ rm -rf /tmp/test-repo | |||
| 150 | ```rust | 150 | ```rust |
| 151 | #[tokio::test] | 151 | #[tokio::test] |
| 152 | async fn test_unauthorized_push() { | 152 | async fn test_unauthorized_push() { |
| 153 | let state = create_test_state().await; | 153 | let relay = TestRelay::start().await; |
| 154 | let result = validate_push(&state, "refs/heads/main", alice_pubkey).await; | 154 | let result = validate_push(&state, "refs/heads/main", alice_pubkey).await; |
| 155 | assert!(result.is_err()); | 155 | assert!(result.is_err()); |
| 156 | relay.stop().await; | ||
| 156 | } | 157 | } |
| 157 | ``` | 158 | ``` |
| 158 | 159 | ||
| 159 | **Result:** Pure Rust unit tests, no shell scripts, no Git setup. | 160 | **Result:** Pure Rust unit tests, no shell scripts, no Git setup. |
| 160 | 161 | ||
| 162 | See [`tests/push_authorization.rs`](tests/push_authorization.rs) for actual test examples. | ||
| 163 | |||
| 161 | ### 4. Shared State and Types | 164 | ### 4. Shared State and Types |
| 162 | 165 | ||
| 163 | **With hooks:** | 166 | **With hooks:** |
| @@ -168,19 +171,17 @@ async fn test_unauthorized_push() { | |||
| 168 | 171 | ||
| 169 | **With inline authorization:** | 172 | **With inline authorization:** |
| 170 | ```rust | 173 | ```rust |
| 171 | pub struct GitHandler { | 174 | // From src/git/handlers.rs |
| 172 | nostr_relay: Arc<NostrRelay>, // Shared! | 175 | pub async fn handle_receive_pack( |
| 173 | state_cache: Arc<StateCache>, // Shared! | 176 | repo_path: PathBuf, |
| 174 | } | 177 | body: Bytes, |
| 175 | 178 | database: SharedDatabase, // Shared with Nostr relay! | |
| 176 | impl GitHandler { | 179 | npub: &str, |
| 177 | async fn validate_push(&self, refs: &[RefUpdate]) -> Result<()> { | 180 | identifier: &str, |
| 178 | // Direct access to Nostr state | 181 | ) -> Result<Response<Full<Bytes>>, GitError> { |
| 179 | let state = self.state_cache.get_latest().await?; | 182 | // Direct database access for authorization |
| 180 | // Validate using shared types | 183 | let auth = get_authorization_for_owner(&database, pubkey, identifier).await?; |
| 181 | state.validate_refs(refs)?; | 184 | // ... |
| 182 | Ok(()) | ||
| 183 | } | ||
| 184 | } | 185 | } |
| 185 | ``` | 186 | ``` |
| 186 | 187 | ||
| @@ -208,9 +209,9 @@ Setup steps: | |||
| 208 | **With inline authorization (ngit-grasp):** | 209 | **With inline authorization (ngit-grasp):** |
| 209 | ``` | 210 | ``` |
| 210 | Single Rust binary: | 211 | Single Rust binary: |
| 211 | - HTTP server (actix-web) | 212 | - HTTP server (Hyper) |
| 212 | - Git protocol handler | 213 | - Git protocol handler |
| 213 | - Nostr relay | 214 | - Nostr relay (nostr-relay-builder) |
| 214 | - Authorization logic | 215 | - Authorization logic |
| 215 | 216 | ||
| 216 | Setup steps: | 217 | Setup steps: |
| @@ -235,44 +236,38 @@ Content-Type: application/x-git-receive-pack-request | |||
| 235 | 0000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status | 236 | 0000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status |
| 236 | ``` | 237 | ``` |
| 237 | 238 | ||
| 238 | We parse this **before** spawning Git: | 239 | We parse this **before** spawning Git. See [`src/git/authorization.rs`](src/git/authorization.rs) for the implementation: |
| 239 | 240 | ||
| 240 | ```rust | 241 | ```rust |
| 241 | pub async fn git_receive_pack( | 242 | /// Parse ref updates from git-receive-pack request body |
| 242 | req: HttpRequest, | 243 | pub fn parse_pushed_refs(body: &[u8]) -> Result<Vec<PushedRef>, AuthorizationError> { |
| 243 | body: web::Bytes, | 244 | // Parse pkt-line format |
| 244 | ) -> Result<HttpResponse, Error> { | 245 | // Extract ref updates |
| 245 | // 1. Parse ref updates from request body | 246 | // Return structured data |
| 246 | let ref_updates = parse_ref_updates(&body)?; | ||
| 247 | |||
| 248 | // 2. Validate against Nostr state | ||
| 249 | let state = get_latest_state(&repo).await?; | ||
| 250 | validate_push(&state, &ref_updates).await?; | ||
| 251 | |||
| 252 | // 3. If valid, spawn git-receive-pack | ||
| 253 | spawn_git_receive_pack(req, body).await | ||
| 254 | } | 247 | } |
| 255 | ``` | 248 | ``` |
| 256 | 249 | ||
| 257 | ### How We Validate | 250 | ### How We Validate |
| 258 | 251 | ||
| 259 | Validation checks: | 252 | Validation checks (from [`src/git/authorization.rs`](src/git/authorization.rs)): |
| 253 | |||
| 260 | 1. Does pusher's pubkey have write access? | 254 | 1. Does pusher's pubkey have write access? |
| 261 | 2. Are they listed as a maintainer in the latest state event? | 255 | 2. Are they listed as a maintainer in the latest state event? |
| 262 | 3. Do maintainer sets form a valid chain? | 256 | 3. Do the refs match the state event? |
| 263 | 257 | ||
| 264 | ```rust | 258 | ```rust |
| 265 | async fn validate_push( | 259 | /// Validate that pushed refs match the authorized state |
| 266 | state: &RepoState, | 260 | pub fn validate_push_refs( |
| 267 | refs: &[RefUpdate], | 261 | pushed_refs: &[PushedRef], |
| 268 | ) -> Result<()> { | 262 | state: &RepositoryState, |
| 269 | for ref_update in refs { | 263 | ) -> Result<(), AuthorizationError> { |
| 270 | // Check if pusher is authorized for this ref | 264 | for pushed_ref in pushed_refs { |
| 271 | if !state.is_authorized(&ref_update.name, pusher_pubkey) { | 265 | if pushed_ref.ref_name.starts_with("refs/heads/") { |
| 272 | return Err(Error::Unauthorized { | 266 | // Validate branch against state |
| 273 | ref_name: ref_update.name.clone(), | 267 | } else if pushed_ref.ref_name.starts_with("refs/tags/") { |
| 274 | pubkey: pusher_pubkey, | 268 | // Validate tag against state |
| 275 | }); | 269 | } else if pushed_ref.ref_name.starts_with("refs/nostr/") { |
| 270 | // Allow refs/nostr/<event-id> for PRs | ||
| 276 | } | 271 | } |
| 277 | } | 272 | } |
| 278 | Ok(()) | 273 | Ok(()) |
| @@ -291,7 +286,7 @@ async fn validate_push( | |||
| 291 | | **Performance** | Spawns Git first | Validates first | | 286 | | **Performance** | Spawns Git first | Validates first | |
| 292 | | **Testing** | Shell scripts + Go tests | Pure Rust tests | | 287 | | **Testing** | Shell scripts + Go tests | Pure Rust tests | |
| 293 | | **Deployment** | Docker + supervisord | Single binary | | 288 | | **Deployment** | Docker + supervisord | Single binary | |
| 294 | | **State sharing** | WebSocket query | Direct memory access | | 289 | | **State sharing** | WebSocket query | Direct database access | |
| 295 | 290 | ||
| 296 | Both are GRASP-compliant, but inline authorization is simpler and more efficient. | 291 | Both are GRASP-compliant, but inline authorization is simpler and more efficient. |
| 297 | 292 | ||
| @@ -314,34 +309,25 @@ Both are GRASP-compliant, but inline authorization is simpler and more efficient | |||
| 314 | ### Is It Worth It? | 309 | ### Is It Worth It? |
| 315 | 310 | ||
| 316 | **Yes**, because: | 311 | **Yes**, because: |
| 317 | 1. The `git-http-backend` crate handles protocol parsing | 312 | 1. We handle protocol parsing in [`src/git/protocol.rs`](src/git/protocol.rs) |
| 318 | 2. GRASP is already non-standard (Nostr authorization) | 313 | 2. GRASP is already non-standard (Nostr authorization) |
| 319 | 3. Benefits far outweigh the coupling cost | 314 | 3. Benefits far outweigh the coupling cost |
| 320 | 4. We can still add hook support later if needed | 315 | 4. We can still add hook support later if needed |
| 321 | 316 | ||
| 322 | --- | 317 | --- |
| 323 | 318 | ||
| 324 | ## Alternative Considered: Hybrid Approach | 319 | ## Implementation References |
| 325 | |||
| 326 | We could use **both** inline validation and hooks: | ||
| 327 | |||
| 328 | ```rust | ||
| 329 | // Inline: Fast path for common cases | ||
| 330 | if !quick_validate(pusher).await? { | ||
| 331 | return Err(Error::Unauthorized); | ||
| 332 | } | ||
| 333 | |||
| 334 | // Hook: Detailed validation | ||
| 335 | spawn_git_with_hook().await?; | ||
| 336 | ``` | ||
| 337 | 320 | ||
| 338 | **Why we didn't choose this:** | 321 | Key files in the ngit-grasp implementation: |
| 339 | - Added complexity | ||
| 340 | - Redundant validation | ||
| 341 | - Slower (two validation steps) | ||
| 342 | - Harder to maintain | ||
| 343 | 322 | ||
| 344 | If inline validation is sufficient, why add hooks? | 323 | | Component | Location | |
| 324 | |-----------|----------| | ||
| 325 | | HTTP routing | [`src/http/mod.rs`](src/http/mod.rs) | | ||
| 326 | | Git handlers | [`src/git/handlers.rs`](src/git/handlers.rs) | | ||
| 327 | | Push authorization | [`src/git/authorization.rs`](src/git/authorization.rs) | | ||
| 328 | | Git protocol parsing | [`src/git/protocol.rs`](src/git/protocol.rs) | | ||
| 329 | | Subprocess management | [`src/git/subprocess.rs`](src/git/subprocess.rs) | | ||
| 330 | | Event acceptance policy | [`src/nostr/builder.rs:51`](src/nostr/builder.rs:51) - `Nip34WritePolicy` | | ||
| 345 | 331 | ||
| 346 | --- | 332 | --- |
| 347 | 333 | ||
| @@ -365,9 +351,8 @@ This would allow: | |||
| 365 | 351 | ||
| 366 | ### If Git Protocol Changes | 352 | ### If Git Protocol Changes |
| 367 | 353 | ||
| 368 | The `git-http-backend` crate abstracts protocol details. If the Git protocol changes: | 354 | The protocol parsing is isolated in [`src/git/protocol.rs`](src/git/protocol.rs). If the Git protocol changes: |
| 369 | - Update the crate dependency | 355 | - Update the protocol module |
| 370 | - Adjust our ref parsing if needed | ||
| 371 | - Tests will catch any breakage | 356 | - Tests will catch any breakage |
| 372 | 357 | ||
| 373 | --- | 358 | --- |
| @@ -380,11 +365,11 @@ The `git-http-backend` crate abstracts protocol details. If the Git protocol cha | |||
| 380 | 2. It's more performant (early rejection) | 365 | 2. It's more performant (early rejection) |
| 381 | 3. It's easier to test (pure Rust) | 366 | 3. It's easier to test (pure Rust) |
| 382 | 4. It's simpler to deploy (single binary) | 367 | 4. It's simpler to deploy (single binary) |
| 383 | 5. It enables better integration (shared state) | 368 | 5. It enables better integration (shared database) |
| 384 | 369 | ||
| 385 | The trade-off (coupling to Git HTTP protocol) is acceptable because: | 370 | The trade-off (coupling to Git HTTP protocol) is acceptable because: |
| 386 | - The protocol is stable and well-specified | 371 | - The protocol is stable and well-specified |
| 387 | - The `git-http-backend` crate abstracts details | 372 | - Protocol handling is isolated in one module |
| 388 | - Benefits far outweigh the cost | 373 | - Benefits far outweigh the cost |
| 389 | 374 | ||
| 390 | This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**. | 375 | This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**. |
| @@ -397,7 +382,8 @@ This decision aligns with our goal of creating a **developer-friendly, productio | |||
| 397 | - [Design Decisions](decisions.md) - All architectural choices | 382 | - [Design Decisions](decisions.md) - All architectural choices |
| 398 | - [Comparison with ngit-relay](comparison.md) - Detailed comparison | 383 | - [Comparison with ngit-relay](comparison.md) - Detailed comparison |
| 399 | - [Git Protocol Reference](../reference/git-protocol.md) - Protocol details | 384 | - [Git Protocol Reference](../reference/git-protocol.md) - Protocol details |
| 385 | - [Test Strategy](../reference/test-strategy.md) - How we test this | ||
| 400 | 386 | ||
| 401 | --- | 387 | --- |
| 402 | 388 | ||
| 403 | *Part of the [ngit-grasp explanation docs](./)* | 389 | *Part of the [ngit-grasp explanation docs](./)* \ No newline at end of file |
diff --git a/docs/learnings/grasp-audit.md b/docs/learnings/grasp-audit.md index 14e5a2b..f4620d9 100644 --- a/docs/learnings/grasp-audit.md +++ b/docs/learnings/grasp-audit.md | |||
| @@ -1,13 +1,13 @@ | |||
| 1 | # GRASP Audit Tool - Patterns and Learnings | 1 | # GRASP Audit Tool - Patterns and Learnings |
| 2 | 2 | ||
| 3 | **Purpose:** Document grasp-audit architecture, patterns, and lessons learned | 3 | **Purpose:** Document grasp-audit architecture, patterns, and lessons learned |
| 4 | **Last Updated:** November 4, 2025 | 4 | **Last Updated:** December 4, 2025 |
| 5 | 5 | ||
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| 8 | ## Overview | 8 | ## Overview |
| 9 | 9 | ||
| 10 | `grasp-audit` is a compliance testing tool for GRASP (Git Relays Authorized via Signed-Nostr Proofs) protocol implementations. It tests both Nostr relay compliance (NIP-01) and GRASP-specific functionality. | 10 | `grasp-audit` is a **fully implemented** compliance testing tool for GRASP (Git Relays Authorized via Signed-Nostr Proofs) protocol implementations. It tests both Nostr relay compliance (NIP-01) and GRASP-specific functionality. |
| 11 | 11 | ||
| 12 | --- | 12 | --- |
| 13 | 13 | ||
| @@ -32,10 +32,10 @@ | |||
| 32 | 32 | ||
| 33 | **Problem:** Test events pollute the relay and need cleanup without deletion events. | 33 | **Problem:** Test events pollute the relay and need cleanup without deletion events. |
| 34 | 34 | ||
| 35 | **Solution:** Use special tags to mark audit events: | 35 | **Solution:** Use special tags to mark audit events (implemented in [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs)): |
| 36 | 36 | ||
| 37 | ```rust | 37 | ```rust |
| 38 | // Every audit event includes these tags | 38 | // Every audit event includes these tags (added automatically) |
| 39 | [ | 39 | [ |
| 40 | ["t", "grasp-audit-test-event"], // Marker | 40 | ["t", "grasp-audit-test-event"], // Marker |
| 41 | ["t", "audit-{run-id}"], // Run isolation | 41 | ["t", "audit-{run-id}"], // Run isolation |
| @@ -78,6 +78,8 @@ | |||
| 78 | 78 | ||
| 79 | ### Audit Configuration | 79 | ### Audit Configuration |
| 80 | 80 | ||
| 81 | From [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs): | ||
| 82 | |||
| 81 | ```rust | 83 | ```rust |
| 82 | use grasp_audit::audit::AuditConfig; | 84 | use grasp_audit::audit::AuditConfig; |
| 83 | 85 | ||
| @@ -101,100 +103,41 @@ let config = AuditConfig::shared(); | |||
| 101 | 103 | ||
| 102 | ### Creating Audit Events | 104 | ### Creating Audit Events |
| 103 | 105 | ||
| 104 | ```rust | 106 | From [`grasp-audit/src/client.rs`](grasp-audit/src/client.rs): |
| 105 | use grasp_audit::audit::{AuditConfig, AuditEventBuilder}; | ||
| 106 | use nostr_sdk::prelude::*; | ||
| 107 | |||
| 108 | let config = AuditConfig::isolated(); | ||
| 109 | let keys = Keys::generate(); | ||
| 110 | |||
| 111 | // Create audit event | ||
| 112 | let event = AuditEventBuilder::new(&config, Kind::TextNote, "test content") | ||
| 113 | .build(&keys)?; | ||
| 114 | |||
| 115 | // Event automatically includes: | ||
| 116 | // - Audit marker tag | ||
| 117 | // - Run ID tag | ||
| 118 | // - Cleanup timestamp tag | ||
| 119 | ``` | ||
| 120 | |||
| 121 | --- | ||
| 122 | |||
| 123 | ### Querying Audit Events | ||
| 124 | 107 | ||
| 125 | ```rust | 108 | ```rust |
| 126 | use grasp_audit::client::AuditClient; | 109 | use grasp_audit::client::AuditClient; |
| 127 | use grasp_audit::audit::AuditConfig; | 110 | use grasp_audit::audit::AuditConfig; |
| 128 | 111 | ||
| 129 | let config = AuditConfig::isolated(); | 112 | let config = AuditConfig::isolated(); |
| 130 | let client = AuditClient::new(config, keys); | 113 | let client = AuditClient::new("ws://localhost:8080", config).await?; |
| 131 | |||
| 132 | // Connect to relay | ||
| 133 | client.add_relay("ws://localhost:7000").await?; | ||
| 134 | client.connect().await; | ||
| 135 | |||
| 136 | // Query audit events for this run | ||
| 137 | let events = client.query().await?; | ||
| 138 | |||
| 139 | // Events are filtered by: | ||
| 140 | // - "grasp-audit-test-event" marker | ||
| 141 | // - Current run ID | ||
| 142 | ``` | ||
| 143 | |||
| 144 | --- | ||
| 145 | |||
| 146 | ### Test Isolation | ||
| 147 | |||
| 148 | **Each test run is isolated by unique run ID:** | ||
| 149 | |||
| 150 | ```rust | ||
| 151 | // CI mode generates unique UUID per run | ||
| 152 | let config1 = AuditConfig::isolated(); | ||
| 153 | let config2 = AuditConfig::isolated(); | ||
| 154 | |||
| 155 | // config1.run_id != config2.run_id | ||
| 156 | // Tests won't interfere with each other | ||
| 157 | ``` | ||
| 158 | |||
| 159 | **Benefits:** | ||
| 160 | 114 | ||
| 161 | - ✅ Parallel CI/CD runs don't conflict | 115 | // Create and send an event - cleanup tags are added automatically |
| 162 | - ✅ Can run multiple test suites simultaneously | 116 | let event = client.event_builder() |
| 163 | - ✅ Easy to identify which run created which events | 117 | .kind(Kind::TextNote) |
| 164 | - ✅ Cleanup can target specific runs | 118 | .content("test content") |
| 165 | 119 | .build(&keys)?; | |
| 166 | --- | ||
| 167 | |||
| 168 | ### Cleanup Strategy | ||
| 169 | |||
| 170 | **Two-phase cleanup:** | ||
| 171 | |||
| 172 | 1. **Automatic expiry** via cleanup timestamp tag | ||
| 173 | 2. **Manual cleanup** by querying and deleting | ||
| 174 | |||
| 175 | ```rust | ||
| 176 | // Events include cleanup timestamp | ||
| 177 | ["t", "audit-cleanup-after-1730707200"] | ||
| 178 | 120 | ||
| 179 | // Cleanup process: | 121 | client.send_event(event).await?; |
| 180 | // 1. Query events with expired cleanup timestamp | ||
| 181 | // 2. Delete from database directly (no KIND 5) | ||
| 182 | // 3. Avoid deletion event pollution | ||
| 183 | ``` | 122 | ``` |
| 184 | 123 | ||
| 185 | **Implementation:** To be built in relay (not in audit tool) | ||
| 186 | |||
| 187 | --- | 124 | --- |
| 188 | 125 | ||
| 189 | ## Testing Strategy | 126 | ### Test Suites |
| 190 | 127 | ||
| 191 | ### Test Organization | 128 | From [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs): |
| 192 | 129 | ||
| 193 | ``` | 130 | ``` |
| 194 | grasp-audit/src/specs/ | 131 | grasp-audit/src/specs/grasp01/ |
| 195 | ├── nip01_smoke.rs # NIP-01 basic functionality | 132 | ├── mod.rs # Module exports |
| 196 | ├── grasp_01_relay.rs # GRASP-01 relay requirements (planned) | 133 | ├── nip01_smoke.rs # NIP-01 basic functionality |
| 197 | └── mod.rs # Test suite registry | 134 | ├── nip11_document.rs # NIP-11 document tests |
| 135 | ├── event_acceptance_policy.rs # GRASP-01 event rules | ||
| 136 | ├── cors.rs # CORS header tests | ||
| 137 | ├── git_clone.rs # Git clone operations | ||
| 138 | ├── push_authorization.rs # Push validation tests | ||
| 139 | ├── repository_creation.rs # Repository lifecycle | ||
| 140 | └── spec_requirements.rs # Requirement definitions | ||
| 198 | ``` | 141 | ``` |
| 199 | 142 | ||
| 200 | ### Unit vs Integration Tests | 143 | ### Unit vs Integration Tests |
| @@ -229,17 +172,18 @@ mod tests { | |||
| 229 | 172 | ||
| 230 | ```bash | 173 | ```bash |
| 231 | # Unit tests (fast, no dependencies) | 174 | # Unit tests (fast, no dependencies) |
| 232 | cargo test --lib | 175 | cd grasp-audit && nix develop -c cargo test --lib |
| 233 | 176 | ||
| 234 | # Integration tests (requires relay) | 177 | # Integration tests (requires relay via test-ngit-relay.sh) |
| 235 | docker run --rm -p 7000:7000 scsibug/nostr-rs-relay | 178 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test |
| 236 | cargo test -- --ignored | ||
| 237 | ``` | 179 | ``` |
| 238 | 180 | ||
| 239 | --- | 181 | --- |
| 240 | 182 | ||
| 241 | ### Test Result Reporting | 183 | ### Test Result Reporting |
| 242 | 184 | ||
| 185 | From [`grasp-audit/src/result.rs`](grasp-audit/src/result.rs): | ||
| 186 | |||
| 243 | ```rust | 187 | ```rust |
| 244 | use grasp_audit::result::AuditResult; | 188 | use grasp_audit::result::AuditResult; |
| 245 | 189 | ||
| @@ -255,7 +199,7 @@ for result in &results { | |||
| 255 | } | 199 | } |
| 256 | 200 | ||
| 257 | // Summary | 201 | // Summary |
| 258 | let passed = results.iter().filter(|r| r.is_pass()).count(); | 202 | let passed = results.iter().filter(|r| r.passed).count(); |
| 259 | let total = results.len(); | 203 | let total = results.len(); |
| 260 | println!("Results: {}/{} passed ({:.1}%)", | 204 | println!("Results: {}/{} passed ({:.1}%)", |
| 261 | passed, total, (passed as f64 / total as f64) * 100.0); | 205 | passed, total, (passed as f64 / total as f64) * 100.0); |
| @@ -291,7 +235,7 @@ grasp-audit audit \ | |||
| 291 | grasp-audit audit \ | 235 | grasp-audit audit \ |
| 292 | --relay wss://relay.example.com \ | 236 | --relay wss://relay.example.com \ |
| 293 | --mode production \ | 237 | --mode production \ |
| 294 | --run-id "audit-2025-11-04" \ | 238 | --run-id "audit-2025-12-04" \ |
| 295 | --verbose | 239 | --verbose |
| 296 | 240 | ||
| 297 | # Test all specs | 241 | # Test all specs |
| @@ -366,25 +310,23 @@ let events = client.query().await?; | |||
| 366 | 310 | ||
| 367 | --- | 311 | --- |
| 368 | 312 | ||
| 369 | ## Future Enhancements | 313 | ## What's Implemented |
| 370 | 314 | ||
| 371 | ### Planned Features | 315 | ### Completed Features |
| 372 | |||
| 373 | - [ ] **GRASP-01 Test Suite**: Repository announcement and state event tests | ||
| 374 | - [ ] **Test Report Generation**: JSON/HTML output for CI/CD | ||
| 375 | - [ ] **Performance Benchmarks**: Measure relay performance | ||
| 376 | - [ ] **Relay Comparison**: Side-by-side compliance comparison | ||
| 377 | - [ ] **Continuous Monitoring**: Periodic production audits | ||
| 378 | 316 | ||
| 379 | --- | 317 | - ✅ **GRASP-01 Test Suites**: All NIP-01, NIP-11, CORS, event acceptance tests |
| 318 | - ✅ **Spec Requirements Database**: Machine-readable requirements in [`spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs) | ||
| 319 | - ✅ **Automatic Cleanup Tags**: Production-safe event tagging | ||
| 320 | - ✅ **Test Isolation**: UUID run IDs for parallel execution | ||
| 321 | - ✅ **AuditClient**: Nostr client wrapper with audit features | ||
| 322 | - ✅ **Fixture Helpers**: Event creation helpers in [`fixtures.rs`](grasp-audit/src/fixtures.rs) | ||
| 380 | 323 | ||
| 381 | ### Possible Improvements | 324 | ### Future Enhancements |
| 382 | 325 | ||
| 383 | - [ ] **Parallel Test Execution**: Run specs in parallel | 326 | - [ ] **GRASP-02 Test Suite**: Proactive sync tests |
| 384 | - [ ] **Retry Logic**: Handle transient failures | 327 | - [ ] **HTML Report Generation**: Rich CI/CD reports |
| 385 | - [ ] **Custom Assertions**: Domain-specific test helpers | 328 | - [ ] **Performance Benchmarks**: Measure relay performance |
| 386 | - [ ] **Event Diff Tool**: Compare expected vs actual events | 329 | - [ ] **Relay Comparison**: Side-by-side compliance comparison |
| 387 | - [ ] **Cleanup Automation**: Auto-cleanup after tests | ||
| 388 | 330 | ||
| 389 | --- | 331 | --- |
| 390 | 332 | ||
| @@ -403,14 +345,12 @@ let events = client.query().await?; | |||
| 403 | **Solution:** | 345 | **Solution:** |
| 404 | 346 | ||
| 405 | ```bash | 347 | ```bash |
| 406 | # Start relay | 348 | # Use test-ngit-relay.sh for automated relay management |
| 407 | docker run --rm -p 7000:7000 scsibug/nostr-rs-relay | 349 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test |
| 408 | |||
| 409 | # Verify relay is running | ||
| 410 | curl http://localhost:7000 | ||
| 411 | 350 | ||
| 412 | # Run tests | 351 | # Or manually: |
| 413 | cargo test -- --ignored | 352 | docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest |
| 353 | RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored | ||
| 414 | ``` | 354 | ``` |
| 415 | 355 | ||
| 416 | --- | 356 | --- |
| @@ -471,46 +411,39 @@ let config = AuditConfig::isolated(); | |||
| 471 | let config = AuditConfig::shared(); | 411 | let config = AuditConfig::shared(); |
| 472 | ``` | 412 | ``` |
| 473 | 413 | ||
| 474 | ### Event Creation | ||
| 475 | |||
| 476 | ```rust | ||
| 477 | let event = AuditEventBuilder::new(&config, kind, content) | ||
| 478 | .build(&keys)?; | ||
| 479 | ``` | ||
| 480 | |||
| 481 | ### Client Usage | 414 | ### Client Usage |
| 482 | 415 | ||
| 483 | ```rust | 416 | ```rust |
| 484 | let client = AuditClient::new(config, keys); | 417 | let client = AuditClient::new("ws://localhost:7000", config).await?; |
| 485 | client.add_relay("ws://localhost:7000").await?; | 418 | assert!(client.is_connected().await); |
| 486 | client.connect().await; | ||
| 487 | let events = client.query().await?; | ||
| 488 | ``` | 419 | ``` |
| 489 | 420 | ||
| 490 | ### Running Tests | 421 | ### Running Tests |
| 491 | 422 | ||
| 492 | ```bash | 423 | ```bash |
| 493 | # Unit tests | 424 | # Unit tests (from grasp-audit/) |
| 494 | cargo test --lib | 425 | nix develop -c cargo test --lib |
| 495 | 426 | ||
| 496 | # Integration tests | 427 | # Integration tests with ngit-relay |
| 497 | cargo test -- --ignored | 428 | nix develop -c bash test-ngit-relay.sh --mode test |
| 498 | 429 | ||
| 499 | # CLI | 430 | # CLI audit |
| 500 | cargo run -- audit --relay ws://localhost:7000 | 431 | nix develop -c cargo run -- audit --relay ws://localhost:7000 |
| 501 | ``` | 432 | ``` |
| 502 | 433 | ||
| 503 | --- | 434 | ### Key Files |
| 504 | |||
| 505 | ## References | ||
| 506 | 435 | ||
| 507 | - **GRASP Protocol**: https://gitworkshop.dev/danconwaydev.com/grasp | 436 | | File | Purpose | |
| 508 | - **NIP-01**: https://github.com/nostr-protocol/nips/blob/master/01.md | 437 | |------|---------| |
| 509 | - **NIP-34**: https://github.com/nostr-protocol/nips/blob/master/34.md | 438 | | [`grasp-audit/src/lib.rs`](grasp-audit/src/lib.rs) | Public API | |
| 510 | - **grasp-audit README**: `grasp-audit/README.md` | 439 | | [`grasp-audit/src/client.rs`](grasp-audit/src/client.rs) | AuditClient implementation | |
| 511 | - **Tag Migration**: `docs/archive/2025-11-04-tag-migration.md` | 440 | | [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs) | AuditConfig, cleanup tags | |
| 441 | | [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs) | Test suite registry | | ||
| 442 | | [`grasp-audit/src/specs/grasp01/spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs) | Requirement database | | ||
| 512 | 443 | ||
| 513 | --- | 444 | --- |
| 514 | 445 | ||
| 515 | _Last updated: November 4, 2025_ | 446 | ## Related Documentation |
| 516 | _Status: Living document - update as grasp-audit evolves_ | 447 | |
| 448 | - [Test Strategy](../reference/test-strategy.md) - Overall testing approach | ||
| 449 | - [GRASP-01 Implementation](grasp-01-implementation.md) - Main project learnings \ No newline at end of file | ||
diff --git a/docs/reference/test-strategy.md b/docs/reference/test-strategy.md index cc1d5b0..7a31bdf 100644 --- a/docs/reference/test-strategy.md +++ b/docs/reference/test-strategy.md | |||
| @@ -2,15 +2,15 @@ | |||
| 2 | 2 | ||
| 3 | ## Overview | 3 | ## Overview |
| 4 | 4 | ||
| 5 | This document outlines the comprehensive testing strategy for ngit-grasp, including a **reusable GRASP compliance testing tool** that can validate any GRASP implementation against the protocol specification. | 5 | This document describes the testing strategy for ngit-grasp, including the **grasp-audit** reusable compliance testing tool and the **integration tests** in the main repository. |
| 6 | 6 | ||
| 7 | ## Testing Philosophy | 7 | ## Testing Philosophy |
| 8 | 8 | ||
| 9 | 1. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly | 9 | 1. **Specification-Driven**: Tests mirror GRASP-01 protocol structure exactly |
| 10 | 2. **Compliance-First**: Every requirement in the spec has a corresponding test | 10 | 2. **Compliance-First**: Every requirement in the spec has a corresponding test |
| 11 | 3. **Reusable**: Compliance tests can validate any GRASP implementation | 11 | 3. **Reusable**: The grasp-audit tool can validate any GRASP implementation |
| 12 | 4. **Clear Failures**: Test failures cite exact spec lines/sections | 12 | 4. **Isolated**: Each test runs with its own relay instance via [`TestRelay`](tests/common/relay.rs:14) |
| 13 | 5. **Comprehensive**: Unit, integration, and compliance testing | 13 | 5. **Clear Failures**: Test failures cite exact spec requirements |
| 14 | 14 | ||
| 15 | ## Test Pyramid | 15 | ## Test Pyramid |
| 16 | 16 | ||
| @@ -20,1219 +20,294 @@ This document outlines the comprehensive testing strategy for ngit-grasp, includ | |||
| 20 | ╱ E2E╲ ~ 10% End-to-end with real Git | 20 | ╱ E2E╲ ~ 10% End-to-end with real Git |
| 21 | ╱──────╲ | 21 | ╱──────╲ |
| 22 | ╱ ╲ | 22 | ╱ ╲ |
| 23 | ╱Compliance╲ ~ 20% GRASP spec validation | 23 | ╱Compliance╲ ~ 30% GRASP-01 spec validation |
| 24 | ╱────────────╲ | 24 | ╱────────────╲ (grasp-audit) |
| 25 | ╱ ╲ | 25 | ╱ ╲ |
| 26 | ╱ Integration ╲ ~ 30% Component interaction | 26 | ╱ Integration ╲ ~ 30% Component interaction |
| 27 | ╱──────────────────╲ | 27 | ╱──────────────────╲ (tests/) |
| 28 | ╱ ╲ | 28 | ╱ ╲ |
| 29 | ╱ Unit Tests ╲ ~ 40% Individual functions | 29 | ╱ Unit Tests ╲ ~ 30% Individual functions |
| 30 | ╱────────────────────────╲ | 30 | ╱────────────────────────╲ (src/**/tests) |
| 31 | ``` | 31 | ``` |
| 32 | 32 | ||
| 33 | ## GRASP Compliance Testing Tool | 33 | ## Project Structure |
| 34 | 34 | ||
| 35 | ### Design Goals | 35 | ### Actual Test Layout |
| 36 | |||
| 37 | 1. **Reusable**: Can test ngit-grasp or any other GRASP implementation | ||
| 38 | 2. **Spec-Mirrored**: Test structure matches GRASP protocol documents | ||
| 39 | 3. **Clear Reporting**: Failures cite exact spec requirements | ||
| 40 | 4. **Automated**: Can run in CI/CD | ||
| 41 | 5. **Extensible**: Easy to add new GRASP versions (GRASP-02, GRASP-05) | ||
| 42 | |||
| 43 | ### Project Structure | ||
| 44 | 36 | ||
| 45 | ``` | 37 | ``` |
| 46 | grasp-compliance-tests/ | 38 | ngit-grasp/ |
| 47 | ├── Cargo.toml # Standalone crate | 39 | ├── tests/ # Integration tests for ngit-grasp |
| 48 | ├── README.md # Usage instructions | 40 | │ ├── common/ |
| 49 | ├── src/ | 41 | │ │ ├── mod.rs # Test utilities module |
| 50 | │ ├── lib.rs # Public API | 42 | │ │ └── relay.rs # TestRelay fixture |
| 51 | │ ├── client.rs # Test client utilities | 43 | │ ├── nip01_compliance.rs # NIP-01 relay compliance |
| 52 | │ ├── assertions.rs # Spec-based assertions | 44 | │ ├── nip11_document.rs # NIP-11 document tests |
| 53 | │ └── specs/ | 45 | │ ├── nip34_announcements.rs # Repository announcement tests |
| 54 | │ ├── mod.rs # Spec registry | 46 | │ ├── repository_creation.rs # Git repo creation tests |
| 55 | │ ├── grasp_01.rs # GRASP-01 tests | 47 | │ ├── push_authorization.rs # Push validation tests |
| 56 | │ ├── grasp_02.rs # GRASP-02 tests | 48 | │ ├── cors.rs # CORS header tests |
| 57 | │ └── grasp_05.rs # GRASP-05 tests | 49 | │ └── git_clone.rs # Git clone tests |
| 58 | ├── fixtures/ | 50 | │ |
| 59 | │ ├── repos/ # Test repositories | 51 | └── grasp-audit/ # Reusable GRASP compliance tool |
| 60 | │ ├── events/ # Nostr event fixtures | 52 | ├── Cargo.toml |
| 61 | │ └── keys/ # Test keypairs | 53 | ├── flake.nix |
| 62 | └── examples/ | 54 | └── src/ |
| 63 | └── test_implementation.rs # Example usage | 55 | ├── lib.rs # Public API |
| 56 | ├── client.rs # AuditClient | ||
| 57 | ├── audit.rs # AuditConfig, cleanup tags | ||
| 58 | ├── fixtures.rs # Test fixtures | ||
| 59 | └── specs/ | ||
| 60 | └── grasp01/ # GRASP-01 specification tests | ||
| 61 | ├── mod.rs # Module exports | ||
| 62 | ├── nip01_smoke.rs # NIP-01 smoke tests | ||
| 63 | ├── nip11_document.rs | ||
| 64 | ├── event_acceptance_policy.rs | ||
| 65 | ├── cors.rs | ||
| 66 | ├── git_clone.rs | ||
| 67 | ├── push_authorization.rs | ||
| 68 | ├── repository_creation.rs | ||
| 69 | └── spec_requirements.rs # Requirement definitions | ||
| 64 | ``` | 70 | ``` |
| 65 | 71 | ||
| 66 | ### Spec-Mirrored Test Structure | 72 | ## Integration Tests (tests/) |
| 73 | |||
| 74 | ### TestRelay Fixture | ||
| 67 | 75 | ||
| 68 | Each GRASP spec document maps to a test module with identical structure: | 76 | The [`TestRelay`](tests/common/relay.rs:14) fixture provides automatic relay lifecycle management: |
| 69 | 77 | ||
| 70 | ```rust | 78 | ```rust |
| 71 | // src/specs/grasp_01.rs | 79 | // From tests/common/relay.rs |
| 72 | 80 | ||
| 73 | use crate::{TestContext, SpecRequirement, ComplianceResult}; | 81 | /// Test relay fixture that manages relay lifecycle |
| 74 | 82 | /// | |
| 75 | /// GRASP-01 - Core Service Requirements | 83 | /// Automatically starts and stops the ngit-grasp relay for testing. |
| 76 | /// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md | 84 | /// Uses a random port to avoid conflicts and cleans up created repositories. |
| 77 | pub struct Grasp01Spec; | 85 | pub struct TestRelay { |
| 78 | 86 | process: Child, | |
| 79 | impl Grasp01Spec { | 87 | url: String, |
| 80 | /// Run all GRASP-01 compliance tests | 88 | port: u16, |
| 81 | pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult { | 89 | } |
| 82 | let mut results = ComplianceResult::new("GRASP-01"); | 90 | |
| 83 | 91 | impl TestRelay { | |
| 84 | // Section: Nostr Relay | 92 | /// Start a test relay instance |
| 85 | results.add(Self::test_nostr_relay_nip01_compliance(ctx).await); | 93 | pub async fn start() -> Self { ... } |
| 86 | results.add(Self::test_accepts_repository_announcements(ctx).await); | ||
| 87 | results.add(Self::test_accepts_repository_state_announcements(ctx).await); | ||
| 88 | results.add(Self::test_rejects_unlisted_announcements(ctx).await); | ||
| 89 | results.add(Self::test_accepts_related_events(ctx).await); | ||
| 90 | results.add(Self::test_serves_nip11_document(ctx).await); | ||
| 91 | results.add(Self::test_nip11_has_supported_grasps(ctx).await); | ||
| 92 | results.add(Self::test_nip11_has_repo_acceptance_criteria(ctx).await); | ||
| 93 | results.add(Self::test_nip11_has_curation_policy(ctx).await); | ||
| 94 | |||
| 95 | // Section: Git Smart HTTP Service | ||
| 96 | results.add(Self::test_serves_git_at_correct_path(ctx).await); | ||
| 97 | results.add(Self::test_accepts_matching_pushes(ctx).await); | ||
| 98 | results.add(Self::test_rejects_mismatched_pushes(ctx).await); | ||
| 99 | results.add(Self::test_respects_recursive_maintainers(ctx).await); | ||
| 100 | results.add(Self::test_sets_head_from_state(ctx).await); | ||
| 101 | results.add(Self::test_accepts_nostr_refs(ctx).await); | ||
| 102 | results.add(Self::test_rejects_pr_branches(ctx).await); | ||
| 103 | results.add(Self::test_deletes_orphaned_nostr_refs(ctx).await); | ||
| 104 | results.add(Self::test_allows_reachable_sha1_in_want(ctx).await); | ||
| 105 | results.add(Self::test_allows_tip_sha1_in_want(ctx).await); | ||
| 106 | results.add(Self::test_serves_webpage(ctx).await); | ||
| 107 | |||
| 108 | // Section: CORS Support | ||
| 109 | results.add(Self::test_cors_allow_origin(ctx).await); | ||
| 110 | results.add(Self::test_cors_allow_methods(ctx).await); | ||
| 111 | results.add(Self::test_cors_allow_headers(ctx).await); | ||
| 112 | results.add(Self::test_cors_options_request(ctx).await); | ||
| 113 | |||
| 114 | results | ||
| 115 | } | ||
| 116 | |||
| 117 | // ================================================================ | ||
| 118 | // NOSTR RELAY TESTS | ||
| 119 | // ================================================================ | ||
| 120 | |||
| 121 | /// MUST serve a NIP-01 compliant nostr relay at `/` | ||
| 122 | /// | ||
| 123 | /// Spec: GRASP-01, Line 9-10 | ||
| 124 | /// > MUST serve a [NIP-01](https://nips.nostr.com/1) compliant nostr | ||
| 125 | /// > relay at `/` that accepts [git repository announcements]... | ||
| 126 | async fn test_nostr_relay_nip01_compliance(ctx: &TestContext) -> TestResult { | ||
| 127 | TestResult::new( | ||
| 128 | "nostr_relay_nip01_compliance", | ||
| 129 | "GRASP-01:9-10", | ||
| 130 | "MUST serve a NIP-01 compliant nostr relay at `/`", | ||
| 131 | ) | ||
| 132 | .run(async { | ||
| 133 | // Test WebSocket upgrade at / | ||
| 134 | let ws = ctx.connect_websocket("/").await?; | ||
| 135 | |||
| 136 | // Test NIP-01 REQ/EVENT/CLOSE/NOTICE messages | ||
| 137 | ws.send_req("test-sub", vec![]).await?; | ||
| 138 | let response = ws.recv().await?; | ||
| 139 | assert_nip01_eose(response)?; | ||
| 140 | |||
| 141 | Ok(()) | ||
| 142 | }) | ||
| 143 | .await | ||
| 144 | } | ||
| 145 | |||
| 146 | /// MUST reject announcements that do not list the service in both | ||
| 147 | /// `clone` and `relays` tags unless implementing `GRASP-05` | ||
| 148 | /// | ||
| 149 | /// Spec: GRASP-01, Line 12-13 | ||
| 150 | /// > MUST reject [git repository announcements] that do not list the | ||
| 151 | /// > service in both `clone` and `relays` tags unless implementing `GRASP-05`. | ||
| 152 | async fn test_rejects_unlisted_announcements(ctx: &TestContext) -> TestResult { | ||
| 153 | TestResult::new( | ||
| 154 | "rejects_unlisted_announcements", | ||
| 155 | "GRASP-01:12-13", | ||
| 156 | "MUST reject announcements not listing service in clone and relays", | ||
| 157 | ) | ||
| 158 | .run(async { | ||
| 159 | let event = ctx.create_announcement() | ||
| 160 | .without_clone_tag(ctx.domain()) | ||
| 161 | .build() | ||
| 162 | .await?; | ||
| 163 | |||
| 164 | let result = ctx.send_event(event).await?; | ||
| 165 | |||
| 166 | assert_eq!( | ||
| 167 | result.ok, false, | ||
| 168 | "Expected rejection of announcement without clone tag" | ||
| 169 | ); | ||
| 170 | assert!( | ||
| 171 | result.message.contains("clone") || result.message.contains("relays"), | ||
| 172 | "Expected rejection message to mention clone/relays requirement" | ||
| 173 | ); | ||
| 174 | |||
| 175 | Ok(()) | ||
| 176 | }) | ||
| 177 | .await | ||
| 178 | } | ||
| 179 | |||
| 180 | /// MUST accept other events that tag, or are tagged by, accepted announcements | ||
| 181 | /// | ||
| 182 | /// Spec: GRASP-01, Line 17-20 | ||
| 183 | /// > MUST accept other events that tag, or are tagged by, either: | ||
| 184 | /// > 1. accepted [git repository announcements]; or | ||
| 185 | /// > 2. accepted [issues] or [patches] | ||
| 186 | async fn test_accepts_related_events(ctx: &TestContext) -> TestResult { | ||
| 187 | TestResult::new( | ||
| 188 | "accepts_related_events", | ||
| 189 | "GRASP-01:17-20", | ||
| 190 | "MUST accept events that tag or are tagged by accepted announcements", | ||
| 191 | ) | ||
| 192 | .run(async { | ||
| 193 | // First, create and accept an announcement | ||
| 194 | let announcement = ctx.create_announcement() | ||
| 195 | .with_clone_tag(ctx.domain()) | ||
| 196 | .with_relay_tag(ctx.domain()) | ||
| 197 | .build() | ||
| 198 | .await?; | ||
| 199 | |||
| 200 | ctx.send_event(announcement.clone()).await?; | ||
| 201 | |||
| 202 | // Now send an issue that tags the announcement | ||
| 203 | let issue = ctx.create_issue() | ||
| 204 | .tag_announcement(&announcement) | ||
| 205 | .build() | ||
| 206 | .await?; | ||
| 207 | |||
| 208 | let result = ctx.send_event(issue).await?; | ||
| 209 | |||
| 210 | assert_eq!( | ||
| 211 | result.ok, true, | ||
| 212 | "Expected acceptance of issue tagging accepted announcement" | ||
| 213 | ); | ||
| 214 | |||
| 215 | Ok(()) | ||
| 216 | }) | ||
| 217 | .await | ||
| 218 | } | ||
| 219 | |||
| 220 | /// MUST serve a NIP-11 document with required fields | ||
| 221 | /// | ||
| 222 | /// Spec: GRASP-01, Line 24-27 | ||
| 223 | /// > MUST serve a [NIP-11] document: | ||
| 224 | /// > 1. MUST list each supported GRASP under `supported_grasps` | ||
| 225 | /// > 2. MUST list repository acceptance criteria under `repo_acceptance_criteria` | ||
| 226 | /// > 3. MUST list curation policy under `curation` if events are curated | ||
| 227 | async fn test_serves_nip11_document(ctx: &TestContext) -> TestResult { | ||
| 228 | TestResult::new( | ||
| 229 | "serves_nip11_document", | ||
| 230 | "GRASP-01:24-27", | ||
| 231 | "MUST serve a NIP-11 document", | ||
| 232 | ) | ||
| 233 | .run(async { | ||
| 234 | let nip11 = ctx.fetch_nip11().await?; | ||
| 235 | |||
| 236 | assert!( | ||
| 237 | nip11.contains_key("supported_nips"), | ||
| 238 | "NIP-11 document must have supported_nips" | ||
| 239 | ); | ||
| 240 | |||
| 241 | Ok(()) | ||
| 242 | }) | ||
| 243 | .await | ||
| 244 | } | ||
| 245 | |||
| 246 | /// NIP-11 MUST list supported GRASPs | ||
| 247 | /// | ||
| 248 | /// Spec: GRASP-01, Line 25 | ||
| 249 | /// > 1. MUST list each supported GRASP under `supported_grasps` | ||
| 250 | /// > in format `GRASP-XX` eg `GRASP-01` as a string array | ||
| 251 | async fn test_nip11_has_supported_grasps(ctx: &TestContext) -> TestResult { | ||
| 252 | TestResult::new( | ||
| 253 | "nip11_has_supported_grasps", | ||
| 254 | "GRASP-01:25", | ||
| 255 | "NIP-11 MUST list supported_grasps as string array", | ||
| 256 | ) | ||
| 257 | .run(async { | ||
| 258 | let nip11 = ctx.fetch_nip11().await?; | ||
| 259 | |||
| 260 | let grasps = nip11.get("supported_grasps") | ||
| 261 | .ok_or("NIP-11 missing supported_grasps field")? | ||
| 262 | .as_array() | ||
| 263 | .ok_or("supported_grasps must be an array")?; | ||
| 264 | |||
| 265 | assert!( | ||
| 266 | grasps.iter().any(|g| g.as_str() == Some("GRASP-01")), | ||
| 267 | "supported_grasps must include 'GRASP-01'" | ||
| 268 | ); | ||
| 269 | |||
| 270 | // Validate format: GRASP-XX | ||
| 271 | for grasp in grasps { | ||
| 272 | let s = grasp.as_str().ok_or("GRASP must be a string")?; | ||
| 273 | assert!( | ||
| 274 | s.starts_with("GRASP-") && s.len() >= 8, | ||
| 275 | "GRASP format must be 'GRASP-XX', got: {}", s | ||
| 276 | ); | ||
| 277 | } | ||
| 278 | |||
| 279 | Ok(()) | ||
| 280 | }) | ||
| 281 | .await | ||
| 282 | } | ||
| 283 | |||
| 284 | // ================================================================ | ||
| 285 | // GIT SMART HTTP SERVICE TESTS | ||
| 286 | // ================================================================ | ||
| 287 | |||
| 288 | /// MUST serve a git repository via git smart http at /<npub>/<identifier>.git | ||
| 289 | /// | ||
| 290 | /// Spec: GRASP-01, Line 31-32 | ||
| 291 | /// > MUST serve a git repository via an unauthenticated [git smart http service] | ||
| 292 | /// > at `/<npub>/<identifier>.git` for each accepted announcement | ||
| 293 | async fn test_serves_git_at_correct_path(ctx: &TestContext) -> TestResult { | ||
| 294 | TestResult::new( | ||
| 295 | "serves_git_at_correct_path", | ||
| 296 | "GRASP-01:31-32", | ||
| 297 | "MUST serve git at /<npub>/<identifier>.git", | ||
| 298 | ) | ||
| 299 | .run(async { | ||
| 300 | // Create and send announcement | ||
| 301 | let announcement = ctx.create_announcement() | ||
| 302 | .with_identifier("test-repo") | ||
| 303 | .with_clone_tag(ctx.domain()) | ||
| 304 | .with_relay_tag(ctx.domain()) | ||
| 305 | .build() | ||
| 306 | .await?; | ||
| 307 | |||
| 308 | let npub = announcement.author_npub(); | ||
| 309 | ctx.send_event(announcement).await?; | ||
| 310 | |||
| 311 | // Wait for repo creation | ||
| 312 | tokio::time::sleep(Duration::from_secs(2)).await; | ||
| 313 | |||
| 314 | // Test git info/refs endpoint | ||
| 315 | let path = format!("/{}/test-repo.git/info/refs?service=git-upload-pack", npub); | ||
| 316 | let response = ctx.http_get(&path).await?; | ||
| 317 | |||
| 318 | assert_eq!( | ||
| 319 | response.status(), 200, | ||
| 320 | "Git info/refs must return 200 OK" | ||
| 321 | ); | ||
| 322 | |||
| 323 | assert_eq!( | ||
| 324 | response.headers().get("content-type").unwrap(), | ||
| 325 | "application/x-git-upload-pack-advertisement", | ||
| 326 | "Git info/refs must have correct content-type" | ||
| 327 | ); | ||
| 328 | |||
| 329 | Ok(()) | ||
| 330 | }) | ||
| 331 | .await | ||
| 332 | } | ||
| 333 | |||
| 334 | /// MUST accept pushes that match the latest state announcement | ||
| 335 | /// | ||
| 336 | /// Spec: GRASP-01, Line 34-35 | ||
| 337 | /// > MUST accept pushes via this service that match the latest | ||
| 338 | /// > [repo state announcement] on the relay, respecting the recursive maintainer set. | ||
| 339 | async fn test_accepts_matching_pushes(ctx: &TestContext) -> TestResult { | ||
| 340 | TestResult::new( | ||
| 341 | "accepts_matching_pushes", | ||
| 342 | "GRASP-01:34-35", | ||
| 343 | "MUST accept pushes matching latest state announcement", | ||
| 344 | ) | ||
| 345 | .run(async { | ||
| 346 | // Setup: Create repo with announcement and state | ||
| 347 | let (announcement, state) = ctx.create_repo_with_state() | ||
| 348 | .branch("main", "a1b2c3d4...") | ||
| 349 | .build() | ||
| 350 | .await?; | ||
| 351 | |||
| 352 | // Push matching state | ||
| 353 | let result = ctx.git_push(&announcement, "main", "a1b2c3d4...").await?; | ||
| 354 | |||
| 355 | assert!( | ||
| 356 | result.success, | ||
| 357 | "Push matching state must succeed, got: {}", result.stderr | ||
| 358 | ); | ||
| 359 | |||
| 360 | Ok(()) | ||
| 361 | }) | ||
| 362 | .await | ||
| 363 | } | ||
| 364 | |||
| 365 | /// MUST reject pushes that don't match the state announcement | ||
| 366 | /// | ||
| 367 | /// Spec: GRASP-01, Line 34-35 (inverse requirement) | ||
| 368 | /// Implied by "MUST accept pushes... that match" | ||
| 369 | async fn test_rejects_mismatched_pushes(ctx: &TestContext) -> TestResult { | ||
| 370 | TestResult::new( | ||
| 371 | "rejects_mismatched_pushes", | ||
| 372 | "GRASP-01:34-35", | ||
| 373 | "MUST reject pushes not matching state announcement", | ||
| 374 | ) | ||
| 375 | .run(async { | ||
| 376 | // Setup: Create repo with state pointing to commit A | ||
| 377 | let (announcement, state) = ctx.create_repo_with_state() | ||
| 378 | .branch("main", "aaaa1111...") | ||
| 379 | .build() | ||
| 380 | .await?; | ||
| 381 | |||
| 382 | // Try to push different commit B | ||
| 383 | let result = ctx.git_push(&announcement, "main", "bbbb2222...").await; | ||
| 384 | |||
| 385 | assert!( | ||
| 386 | result.is_err() || !result.unwrap().success, | ||
| 387 | "Push not matching state must be rejected" | ||
| 388 | ); | ||
| 389 | |||
| 390 | Ok(()) | ||
| 391 | }) | ||
| 392 | .await | ||
| 393 | } | ||
| 394 | |||
| 395 | /// MUST accept pushes to refs/nostr/<event-id> | ||
| 396 | /// | ||
| 397 | /// Spec: GRASP-01, Line 42-44 | ||
| 398 | /// > MUST accept pushes via this service to `refs/nostr/<event-id>` but | ||
| 399 | /// > SHOULD reject if event exists on relay listing a different tip | ||
| 400 | async fn test_accepts_nostr_refs(ctx: &TestContext) -> TestResult { | ||
| 401 | TestResult::new( | ||
| 402 | "accepts_nostr_refs", | ||
| 403 | "GRASP-01:42-44", | ||
| 404 | "MUST accept pushes to refs/nostr/<event-id>", | ||
| 405 | ) | ||
| 406 | .run(async { | ||
| 407 | let (announcement, _) = ctx.create_repo_with_state().build().await?; | ||
| 408 | |||
| 409 | // Create a PR event | ||
| 410 | let pr_event = ctx.create_pr_event() | ||
| 411 | .for_repo(&announcement) | ||
| 412 | .build() | ||
| 413 | .await?; | ||
| 414 | |||
| 415 | let event_id = pr_event.id(); | ||
| 416 | |||
| 417 | // Push to refs/nostr/<event-id> | ||
| 418 | let result = ctx.git_push( | ||
| 419 | &announcement, | ||
| 420 | &format!("refs/nostr/{}", event_id), | ||
| 421 | "commit-sha..." | ||
| 422 | ).await?; | ||
| 423 | |||
| 424 | assert!( | ||
| 425 | result.success, | ||
| 426 | "Push to refs/nostr/<event-id> must succeed" | ||
| 427 | ); | ||
| 428 | |||
| 429 | Ok(()) | ||
| 430 | }) | ||
| 431 | .await | ||
| 432 | } | ||
| 433 | |||
| 434 | /// MUST reject pr/* branches | ||
| 435 | /// | ||
| 436 | /// Spec: GRASP-01, Line 42-44 (implied) | ||
| 437 | /// PRs should use refs/nostr/, not refs/heads/pr/* | ||
| 438 | async fn test_rejects_pr_branches(ctx: &TestContext) -> TestResult { | ||
| 439 | TestResult::new( | ||
| 440 | "rejects_pr_branches", | ||
| 441 | "GRASP-01:42-44", | ||
| 442 | "MUST reject refs/heads/pr/* (use refs/nostr/ instead)", | ||
| 443 | ) | ||
| 444 | .run(async { | ||
| 445 | let (announcement, _) = ctx.create_repo_with_state().build().await?; | ||
| 446 | |||
| 447 | // Try to push to pr/* branch | ||
| 448 | let result = ctx.git_push( | ||
| 449 | &announcement, | ||
| 450 | "refs/heads/pr/123", | ||
| 451 | "commit-sha..." | ||
| 452 | ).await; | ||
| 453 | |||
| 454 | assert!( | ||
| 455 | result.is_err() || !result.unwrap().success, | ||
| 456 | "Push to refs/heads/pr/* must be rejected" | ||
| 457 | ); | ||
| 458 | |||
| 459 | Ok(()) | ||
| 460 | }) | ||
| 461 | .await | ||
| 462 | } | ||
| 463 | |||
| 464 | /// MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want | ||
| 465 | /// | ||
| 466 | /// Spec: GRASP-01, Line 48-49 | ||
| 467 | /// > MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` | ||
| 468 | /// > in advertisement and serve available oids. | ||
| 469 | async fn test_allows_tip_sha1_in_want(ctx: &TestContext) -> TestResult { | ||
| 470 | TestResult::new( | ||
| 471 | "allows_tip_sha1_in_want", | ||
| 472 | "GRASP-01:48-49", | ||
| 473 | "MUST advertise and support allow-tip-sha1-in-want", | ||
| 474 | ) | ||
| 475 | .run(async { | ||
| 476 | let (announcement, _) = ctx.create_repo_with_state() | ||
| 477 | .branch("main", "a1b2c3d4...") | ||
| 478 | .build() | ||
| 479 | .await?; | ||
| 480 | |||
| 481 | // Fetch git capabilities | ||
| 482 | let caps = ctx.git_capabilities(&announcement).await?; | ||
| 483 | |||
| 484 | assert!( | ||
| 485 | caps.contains("allow-tip-sha1-in-want"), | ||
| 486 | "Git advertisement must include allow-tip-sha1-in-want" | ||
| 487 | ); | ||
| 488 | |||
| 489 | assert!( | ||
| 490 | caps.contains("allow-reachable-sha1-in-want"), | ||
| 491 | "Git advertisement must include allow-reachable-sha1-in-want" | ||
| 492 | ); | ||
| 493 | |||
| 494 | Ok(()) | ||
| 495 | }) | ||
| 496 | .await | ||
| 497 | } | ||
| 498 | 94 | ||
| 499 | // ================================================================ | 95 | /// Get the relay WebSocket URL |
| 500 | // CORS SUPPORT TESTS | 96 | pub fn url(&self) -> &str { ... } |
| 501 | // ================================================================ | ||
| 502 | 97 | ||
| 503 | /// MUST set Access-Control-Allow-Origin: * on ALL responses | 98 | /// Get the relay domain (host:port) |
| 504 | /// | 99 | pub fn domain(&self) -> String { ... } |
| 505 | /// Spec: GRASP-01, Line 57 | ||
| 506 | /// > 1. Set `Access-Control-Allow-Origin: *` on ALL responses | ||
| 507 | async fn test_cors_allow_origin(ctx: &TestContext) -> TestResult { | ||
| 508 | TestResult::new( | ||
| 509 | "cors_allow_origin", | ||
| 510 | "GRASP-01:57", | ||
| 511 | "MUST set Access-Control-Allow-Origin: * on ALL responses", | ||
| 512 | ) | ||
| 513 | .run(async { | ||
| 514 | let paths = vec![ | ||
| 515 | "/", | ||
| 516 | "/test-npub/test-repo.git/info/refs?service=git-upload-pack", | ||
| 517 | ]; | ||
| 518 | |||
| 519 | for path in paths { | ||
| 520 | let response = ctx.http_get(path).await?; | ||
| 521 | |||
| 522 | assert_eq!( | ||
| 523 | response.headers().get("access-control-allow-origin").unwrap(), | ||
| 524 | "*", | ||
| 525 | "Path {} must have Access-Control-Allow-Origin: *", path | ||
| 526 | ); | ||
| 527 | } | ||
| 528 | |||
| 529 | Ok(()) | ||
| 530 | }) | ||
| 531 | .await | ||
| 532 | } | ||
| 533 | 100 | ||
| 534 | /// MUST respond to OPTIONS requests with 204 No Content | 101 | /// Stop the relay |
| 535 | /// | 102 | pub async fn stop(mut self) { ... } |
| 536 | /// Spec: GRASP-01, Line 60 | ||
| 537 | /// > 4. Respond to OPTIONS requests with 204 No Content | ||
| 538 | async fn test_cors_options_request(ctx: &TestContext) -> TestResult { | ||
| 539 | TestResult::new( | ||
| 540 | "cors_options_request", | ||
| 541 | "GRASP-01:60", | ||
| 542 | "MUST respond to OPTIONS with 204 No Content", | ||
| 543 | ) | ||
| 544 | .run(async { | ||
| 545 | let response = ctx.http_options("/test-npub/test-repo.git/info/refs").await?; | ||
| 546 | |||
| 547 | assert_eq!( | ||
| 548 | response.status(), 204, | ||
| 549 | "OPTIONS request must return 204 No Content" | ||
| 550 | ); | ||
| 551 | |||
| 552 | Ok(()) | ||
| 553 | }) | ||
| 554 | .await | ||
| 555 | } | ||
| 556 | } | 103 | } |
| 557 | ``` | 104 | ``` |
| 558 | 105 | ||
| 559 | ### Test Result Reporting | 106 | ### Using TestRelay in Integration Tests |
| 107 | |||
| 108 | From [`tests/nip01_compliance.rs`](tests/nip01_compliance.rs): | ||
| 560 | 109 | ||
| 561 | ```rust | 110 | ```rust |
| 562 | /// Test result with spec citation | 111 | use common::TestRelay; |
| 563 | pub struct TestResult { | 112 | use grasp_audit::*; |
| 564 | pub name: String, | ||
| 565 | pub spec_ref: String, // e.g., "GRASP-01:12-13" | ||
| 566 | pub requirement: String, // Exact text from spec | ||
| 567 | pub passed: bool, | ||
| 568 | pub error: Option<String>, | ||
| 569 | pub duration: Duration, | ||
| 570 | } | ||
| 571 | 113 | ||
| 572 | impl TestResult { | 114 | /// Macro to generate isolated integration tests |
| 573 | /// Create a new test result | 115 | macro_rules! isolated_test { |
| 574 | pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { | 116 | ($test_name:ident) => { |
| 575 | TestResult { | 117 | #[tokio::test] |
| 576 | name: name.to_string(), | 118 | async fn $test_name() { |
| 577 | spec_ref: spec_ref.to_string(), | 119 | let relay = TestRelay::start().await; |
| 578 | requirement: requirement.to_string(), | 120 | let config = AuditConfig::isolated(); |
| 579 | passed: false, | 121 | let client = AuditClient::new(relay.url(), config) |
| 580 | error: None, | 122 | .await |
| 581 | duration: Duration::default(), | 123 | .expect("Failed to create audit client"); |
| 582 | } | ||
| 583 | } | ||
| 584 | |||
| 585 | /// Run the test | ||
| 586 | pub async fn run<F, Fut>(mut self, test_fn: F) -> Self | ||
| 587 | where | ||
| 588 | F: FnOnce() -> Fut, | ||
| 589 | Fut: Future<Output = Result<(), String>>, | ||
| 590 | { | ||
| 591 | let start = Instant::now(); | ||
| 592 | |||
| 593 | match test_fn().await { | ||
| 594 | Ok(()) => { | ||
| 595 | self.passed = true; | ||
| 596 | } | ||
| 597 | Err(e) => { | ||
| 598 | self.passed = false; | ||
| 599 | self.error = Some(e); | ||
| 600 | } | ||
| 601 | } | ||
| 602 | |||
| 603 | self.duration = start.elapsed(); | ||
| 604 | self | ||
| 605 | } | ||
| 606 | } | ||
| 607 | 124 | ||
| 608 | /// Collection of test results for a spec | 125 | let result = specs::Nip01SmokeTests::$test_name(&client).await; |
| 609 | pub struct ComplianceResult { | 126 | |
| 610 | pub spec: String, | 127 | relay.stop().await; |
| 611 | pub results: Vec<TestResult>, | ||
| 612 | } | ||
| 613 | 128 | ||
| 614 | impl ComplianceResult { | 129 | assert!( |
| 615 | pub fn report(&self) -> String { | 130 | result.passed, |
| 616 | let mut output = String::new(); | 131 | "{} failed: {}", |
| 617 | 132 | stringify!($test_name), | |
| 618 | output.push_str(&format!("\n{} Compliance Report\n", self.spec)); | 133 | result.error.as_deref().unwrap_or("unknown error") |
| 619 | output.push_str(&"=".repeat(60)); | 134 | ); |
| 620 | output.push_str("\n\n"); | ||
| 621 | |||
| 622 | let passed = self.results.iter().filter(|r| r.passed).count(); | ||
| 623 | let total = self.results.len(); | ||
| 624 | |||
| 625 | output.push_str(&format!("Results: {}/{} passed\n\n", passed, total)); | ||
| 626 | |||
| 627 | for result in &self.results { | ||
| 628 | let status = if result.passed { "✓" } else { "✗" }; | ||
| 629 | |||
| 630 | output.push_str(&format!( | ||
| 631 | "{} {} ({})\n", | ||
| 632 | status, result.name, result.spec_ref | ||
| 633 | )); | ||
| 634 | |||
| 635 | output.push_str(&format!(" Requirement: {}\n", result.requirement)); | ||
| 636 | |||
| 637 | if let Some(error) = &result.error { | ||
| 638 | output.push_str(&format!(" Error: {}\n", error)); | ||
| 639 | } | ||
| 640 | |||
| 641 | output.push_str(&format!(" Duration: {:?}\n\n", result.duration)); | ||
| 642 | } | 135 | } |
| 643 | 136 | }; | |
| 644 | output | ||
| 645 | } | ||
| 646 | } | 137 | } |
| 647 | ``` | ||
| 648 | 138 | ||
| 649 | ### Usage Example | 139 | // Generate isolated tests for all NIP-01 smoke tests |
| 650 | 140 | isolated_test!(test_websocket_connection); | |
| 651 | ```rust | 141 | isolated_test!(test_send_receive_event); |
| 652 | // examples/test_implementation.rs | 142 | isolated_test!(test_create_subscription); |
| 653 | |||
| 654 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | ||
| 655 | |||
| 656 | #[tokio::main] | ||
| 657 | async fn main() { | ||
| 658 | // Configure the implementation to test | ||
| 659 | let ctx = TestContext::builder() | ||
| 660 | .base_url("http://localhost:8080") | ||
| 661 | .websocket_url("ws://localhost:8080") | ||
| 662 | .domain("localhost:8080") | ||
| 663 | .build(); | ||
| 664 | |||
| 665 | // Run GRASP-01 compliance tests | ||
| 666 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 667 | |||
| 668 | // Print report | ||
| 669 | println!("{}", results.report()); | ||
| 670 | |||
| 671 | // Exit with error if any tests failed | ||
| 672 | if !results.all_passed() { | ||
| 673 | std::process::exit(1); | ||
| 674 | } | ||
| 675 | } | ||
| 676 | ``` | 143 | ``` |
| 677 | 144 | ||
| 678 | ### Integration with ngit-grasp | 145 | ### Running Integration Tests |
| 679 | 146 | ||
| 680 | In `ngit-grasp/tests/compliance.rs`: | 147 | ```bash |
| 148 | # Run all integration tests | ||
| 149 | cargo test --test '*' | ||
| 681 | 150 | ||
| 682 | ```rust | 151 | # Run specific test file |
| 683 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | 152 | cargo test --test nip01_compliance |
| 684 | 153 | ||
| 685 | #[tokio::test] | 154 | # Run with output |
| 686 | async fn test_grasp_01_compliance() { | 155 | cargo test --test nip01_compliance -- --nocapture |
| 687 | // Start test server | ||
| 688 | let server = start_test_server().await; | ||
| 689 | |||
| 690 | // Configure test context | ||
| 691 | let ctx = TestContext::builder() | ||
| 692 | .base_url(&server.url()) | ||
| 693 | .websocket_url(&server.ws_url()) | ||
| 694 | .domain(&server.domain()) | ||
| 695 | .build(); | ||
| 696 | |||
| 697 | // Run compliance tests | ||
| 698 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 699 | |||
| 700 | // Assert all tests passed | ||
| 701 | assert!( | ||
| 702 | results.all_passed(), | ||
| 703 | "GRASP-01 compliance failed:\n{}", | ||
| 704 | results.report() | ||
| 705 | ); | ||
| 706 | } | ||
| 707 | ``` | 156 | ``` |
| 708 | 157 | ||
| 709 | ## Unit Testing Strategy | 158 | ## GRASP Audit Tool (grasp-audit/) |
| 710 | 159 | ||
| 711 | ### Git Module Tests | 160 | ### Purpose |
| 712 | 161 | ||
| 713 | ```rust | 162 | The grasp-audit tool is a **reusable GRASP compliance testing library** that can: |
| 714 | // src/git/parser.rs tests | ||
| 715 | 163 | ||
| 716 | #[cfg(test)] | 164 | - Test ngit-grasp for self-validation |
| 717 | mod tests { | 165 | - Test any other GRASP implementation (like ngit-relay) |
| 718 | use super::*; | 166 | - Run in CI/CD for continuous compliance verification |
| 719 | 167 | - Generate compliance reports | |
| 720 | #[test] | ||
| 721 | fn test_parse_pkt_line() { | ||
| 722 | let data = b"0006a\n"; | ||
| 723 | let (length, payload) = parse_pkt_line(data).unwrap(); | ||
| 724 | assert_eq!(length, 6); | ||
| 725 | assert_eq!(payload, b"a\n"); | ||
| 726 | } | ||
| 727 | |||
| 728 | #[test] | ||
| 729 | fn test_parse_flush_packet() { | ||
| 730 | let data = b"0000"; | ||
| 731 | let result = parse_pkt_line(data).unwrap(); | ||
| 732 | assert_eq!(result.0, 0); | ||
| 733 | } | ||
| 734 | |||
| 735 | #[test] | ||
| 736 | fn test_parse_ref_updates() { | ||
| 737 | let body = b"00820000000000000000000000000000000000000000 \ | ||
| 738 | a1b2c3d4e5f6789012345678901234567890abcd \ | ||
| 739 | refs/heads/main\0 report-status\n\ | ||
| 740 | 0000"; | ||
| 741 | |||
| 742 | let updates = parse_ref_updates(body).unwrap(); | ||
| 743 | assert_eq!(updates.len(), 1); | ||
| 744 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 745 | } | ||
| 746 | } | ||
| 747 | ``` | ||
| 748 | 168 | ||
| 749 | ### Authorization Module Tests | 169 | ### Test Suites |
| 750 | 170 | ||
| 751 | ```rust | 171 | From [`grasp-audit/src/specs/grasp01/mod.rs`](grasp-audit/src/specs/grasp01/mod.rs): |
| 752 | // src/git/authorization.rs tests | ||
| 753 | 172 | ||
| 754 | #[cfg(test)] | 173 | | Suite | Description | Requirements | |
| 755 | mod tests { | 174 | |-------|-------------|--------------| |
| 756 | use super::*; | 175 | | [`Nip01SmokeTests`](grasp-audit/src/specs/grasp01/nip01_smoke.rs) | Basic NIP-01 relay functionality | WebSocket only | |
| 757 | 176 | | [`Nip11DocumentTests`](grasp-audit/src/specs/grasp01/nip11_document.rs) | NIP-11 relay information document | WebSocket only | | |
| 758 | #[test] | 177 | | [`EventAcceptancePolicyTests`](grasp-audit/src/specs/grasp01/event_acceptance_policy.rs) | Event acceptance rules | WebSocket only | |
| 759 | fn test_get_maintainers_single() { | 178 | | [`CorsTests`](grasp-audit/src/specs/grasp01/cors.rs) | CORS headers on Git HTTP endpoints | git-data-dir | |
| 760 | let events = vec | Git clone operations | git-data-dir | |
| 761 | create_test_announcement("alice", "repo1", vec![]), | 180 | | [`PushAuthorizationTests`](grasp-audit/src/specs/grasp01/push_authorization.rs) | Push authorization | git-data-dir | |
| 762 | ]; | 181 | | [`RepositoryCreationTests`](grasp-audit/src/specs/grasp01/repository_creation.rs) | Repository creation | git-data-dir | |
| 763 | |||
| 764 | let maintainers = get_maintainers(&events, "alice", "repo1"); | ||
| 765 | assert_eq!(maintainers, vec!["alice"]); | ||
| 766 | } | ||
| 767 | |||
| 768 | #[test] | ||
| 769 | fn test_get_maintainers_recursive() { | ||
| 770 | let events = vec![ | ||
| 771 | create_test_announcement("alice", "repo1", vec!["bob"]), | ||
| 772 | create_test_announcement("bob", "repo1", vec![]), | ||
| 773 | ]; | ||
| 774 | |||
| 775 | let maintainers = get_maintainers(&events, "alice", "repo1"); | ||
| 776 | assert!(maintainers.contains(&"alice".to_string())); | ||
| 777 | assert!(maintainers.contains(&"bob".to_string())); | ||
| 778 | } | ||
| 779 | |||
| 780 | #[test] | ||
| 781 | fn test_get_maintainers_circular() { | ||
| 782 | let events = vec![ | ||
| 783 | create_test_announcement("alice", "repo1", vec!["bob"]), | ||
| 784 | create_test_announcement("bob", "repo1", vec!["alice"]), | ||
| 785 | ]; | ||
| 786 | |||
| 787 | let maintainers = get_maintainers(&events, "alice", "repo1"); | ||
| 788 | assert_eq!(maintainers.len(), 2); | ||
| 789 | } | ||
| 790 | |||
| 791 | #[test] | ||
| 792 | fn test_validate_state_ref_matching() { | ||
| 793 | let state = RepositoryState { | ||
| 794 | branches: HashMap::from([ | ||
| 795 | ("main".into(), "a1b2c3d4...".into()), | ||
| 796 | ]), | ||
| 797 | tags: HashMap::new(), | ||
| 798 | }; | ||
| 799 | |||
| 800 | let update = RefUpdate { | ||
| 801 | old_oid: "0000...".into(), | ||
| 802 | new_oid: "a1b2c3d4...".into(), | ||
| 803 | ref_name: "refs/heads/main".into(), | ||
| 804 | }; | ||
| 805 | |||
| 806 | assert!(validate_state_ref(&state, &update).is_ok()); | ||
| 807 | } | ||
| 808 | |||
| 809 | #[test] | ||
| 810 | fn test_validate_state_ref_mismatch() { | ||
| 811 | let state = RepositoryState { | ||
| 812 | branches: HashMap::from([ | ||
| 813 | ("main".into(), "aaaa1111...".into()), | ||
| 814 | ]), | ||
| 815 | tags: HashMap::new(), | ||
| 816 | }; | ||
| 817 | |||
| 818 | let update = RefUpdate { | ||
| 819 | old_oid: "0000...".into(), | ||
| 820 | new_oid: "bbbb2222...".into(), | ||
| 821 | ref_name: "refs/heads/main".into(), | ||
| 822 | }; | ||
| 823 | |||
| 824 | assert!(validate_state_ref(&state, &update).is_err()); | ||
| 825 | } | ||
| 826 | } | ||
| 827 | ``` | ||
| 828 | 182 | ||
| 829 | ## Integration Testing Strategy | 183 | ### Spec Requirements Database |
| 830 | 184 | ||
| 831 | ### Repository Lifecycle Tests | 185 | From [`grasp-audit/src/specs/grasp01/spec_requirements.rs`](grasp-audit/src/specs/grasp01/spec_requirements.rs): |
| 832 | 186 | ||
| 833 | ```rust | 187 | ```rust |
| 834 | // tests/integration/repository_lifecycle.rs | 188 | pub struct SpecRequirement { |
| 835 | 189 | pub id: &'static str, // e.g., "GRASP-01:L9" | |
| 836 | #[tokio::test] | 190 | pub section: &'static str, // e.g., "Nostr Relay" |
| 837 | async fn test_repository_creation_on_announcement() { | 191 | pub level: RequirementLevel, // MUST, SHOULD, MAY |
| 838 | let app = test_app().await; | 192 | pub text: &'static str, // Exact text from spec |
| 839 | 193 | pub line: u32, // Line number in spec | |
| 840 | // Send repository announcement | ||
| 841 | let announcement = create_announcement() | ||
| 842 | .with_identifier("test-repo") | ||
| 843 | .with_clone_tag(app.domain()) | ||
| 844 | .with_relay_tag(app.domain()) | ||
| 845 | .sign() | ||
| 846 | .await; | ||
| 847 | |||
| 848 | app.send_event(announcement).await.unwrap(); | ||
| 849 | |||
| 850 | // Wait for async processing | ||
| 851 | tokio::time::sleep(Duration::from_secs(1)).await; | ||
| 852 | |||
| 853 | // Verify repository was created | ||
| 854 | let repo_path = app.git_data_path() | ||
| 855 | .join(announcement.author_npub()) | ||
| 856 | .join("test-repo.git"); | ||
| 857 | |||
| 858 | assert!(repo_path.exists()); | ||
| 859 | assert!(repo_path.join("HEAD").exists()); | ||
| 860 | assert!(repo_path.join("config").exists()); | ||
| 861 | } | 194 | } |
| 862 | 195 | ||
| 863 | #[tokio::test] | 196 | pub enum RequirementLevel { |
| 864 | async fn test_push_validation_flow() { | 197 | Must, |
| 865 | let app = test_app().await; | 198 | Should, |
| 866 | 199 | May, | |
| 867 | // Create repository with state | ||
| 868 | let (announcement, state) = app.create_repo_with_state() | ||
| 869 | .branch("main", "commit-sha-123") | ||
| 870 | .build() | ||
| 871 | .await; | ||
| 872 | |||
| 873 | // Attempt push matching state | ||
| 874 | let result = app.git_push("main", "commit-sha-123").await; | ||
| 875 | assert!(result.success); | ||
| 876 | |||
| 877 | // Attempt push NOT matching state | ||
| 878 | let result = app.git_push("main", "different-sha-456").await; | ||
| 879 | assert!(!result.success); | ||
| 880 | assert!(result.stderr.contains("state event")); | ||
| 881 | } | 200 | } |
| 882 | ``` | 201 | ``` |
| 883 | 202 | ||
| 884 | ### Multi-Maintainer Tests | 203 | ### Automatic Cleanup Tags |
| 204 | |||
| 205 | All audit events include cleanup tags for production safety (from [`grasp-audit/src/audit.rs`](grasp-audit/src/audit.rs)): | ||
| 885 | 206 | ||
| 886 | ```rust | 207 | ```rust |
| 887 | #[tokio::test] | 208 | // Automatically added to EVERY audit event: |
| 888 | async fn test_multi_maintainer_push() { | 209 | ["t", "grasp-audit-test-event"] // Marker |
| 889 | let app = test_app().await; | 210 | ["t", "audit-{run_id}"] // Run isolation |
| 890 | 211 | ["t", "audit-cleanup-after-{unix_timestamp}"] // Cleanup time | |
| 891 | // Alice creates repo, lists Bob as maintainer | ||
| 892 | let alice_announcement = create_announcement() | ||
| 893 | .author("alice") | ||
| 894 | .maintainers(vec!["bob"]) | ||
| 895 | .build(); | ||
| 896 | |||
| 897 | app.send_event(alice_announcement).await.unwrap(); | ||
| 898 | |||
| 899 | // Bob creates state event | ||
| 900 | let bob_state = create_state() | ||
| 901 | .author("bob") | ||
| 902 | .branch("main", "commit-123") | ||
| 903 | .build(); | ||
| 904 | |||
| 905 | app.send_event(bob_state).await.unwrap(); | ||
| 906 | |||
| 907 | // Bob's push should succeed | ||
| 908 | let result = app.git_push_as("bob", "main", "commit-123").await; | ||
| 909 | assert!(result.success); | ||
| 910 | } | ||
| 911 | ``` | 212 | ``` |
| 912 | 213 | ||
| 913 | ## End-to-End Testing | 214 | ### Running grasp-audit |
| 914 | 215 | ||
| 915 | ### Real Git Client Tests | 216 | **Testing the reference implementation (ngit-relay):** |
| 916 | 217 | ||
| 917 | ```rust | 218 | ```bash |
| 918 | // tests/e2e/git_client.rs | 219 | # Use test-ngit-relay.sh for automated relay management |
| 919 | 220 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test | |
| 920 | #[tokio::test] | ||
| 921 | async fn test_real_git_clone() { | ||
| 922 | let app = test_app().await; | ||
| 923 | |||
| 924 | // Setup repository | ||
| 925 | let (announcement, _) = app.create_repo_with_commits() | ||
| 926 | .commit("Initial commit", "file.txt", "content") | ||
| 927 | .build() | ||
| 928 | .await; | ||
| 929 | |||
| 930 | // Clone with real git client | ||
| 931 | let temp_dir = TempDir::new().unwrap(); | ||
| 932 | let clone_url = format!( | ||
| 933 | "http://{}/{}/{}.git", | ||
| 934 | app.domain(), | ||
| 935 | announcement.author_npub(), | ||
| 936 | announcement.identifier() | ||
| 937 | ); | ||
| 938 | |||
| 939 | let output = Command::new("git") | ||
| 940 | .args(&["clone", &clone_url]) | ||
| 941 | .current_dir(&temp_dir) | ||
| 942 | .output() | ||
| 943 | .await | ||
| 944 | .unwrap(); | ||
| 945 | |||
| 946 | assert!(output.status.success()); | ||
| 947 | assert!(temp_dir.path().join(announcement.identifier()).exists()); | ||
| 948 | } | ||
| 949 | 221 | ||
| 950 | #[tokio::test] | 222 | # Or manually: |
| 951 | async fn test_real_git_push() { | 223 | docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest |
| 952 | let app = test_app().await; | 224 | cd grasp-audit |
| 953 | 225 | RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture | |
| 954 | // Create repository | ||
| 955 | let (announcement, keys) = app.create_repo().await; | ||
| 956 | |||
| 957 | // Clone it | ||
| 958 | let temp_dir = TempDir::new().unwrap(); | ||
| 959 | git_clone(&app, &announcement, &temp_dir).await; | ||
| 960 | |||
| 961 | // Make changes | ||
| 962 | let repo_dir = temp_dir.path().join(announcement.identifier()); | ||
| 963 | tokio::fs::write(repo_dir.join("new-file.txt"), "content").await.unwrap(); | ||
| 964 | |||
| 965 | // Commit | ||
| 966 | git_commit(&repo_dir, "Add new file").await; | ||
| 967 | |||
| 968 | // Send state event for new commit | ||
| 969 | let new_commit = git_rev_parse(&repo_dir, "HEAD").await; | ||
| 970 | app.send_state(&announcement, "main", &new_commit, &keys).await; | ||
| 971 | |||
| 972 | // Push | ||
| 973 | let output = Command::new("git") | ||
| 974 | .args(&["push", "origin", "main"]) | ||
| 975 | .current_dir(&repo_dir) | ||
| 976 | .output() | ||
| 977 | .await | ||
| 978 | .unwrap(); | ||
| 979 | |||
| 980 | assert!(output.status.success()); | ||
| 981 | } | ||
| 982 | ``` | 226 | ``` |
| 983 | 227 | ||
| 984 | ## Performance Testing | 228 | **Testing ngit-grasp (the main project):** |
| 985 | 229 | ||
| 986 | ### Load Tests | 230 | ```bash |
| 231 | # Integration tests use TestRelay fixture - just run: | ||
| 232 | cargo test --test '*' | ||
| 233 | ``` | ||
| 987 | 234 | ||
| 988 | ```rust | 235 | ## Test Patterns |
| 989 | // tests/performance/load.rs | ||
| 990 | 236 | ||
| 991 | #[tokio::test] | 237 | ### Isolated Test Pattern |
| 992 | async fn test_concurrent_pushes() { | 238 | |
| 993 | let app = test_app().await; | 239 | Each test runs with its own fresh relay instance: |
| 994 | |||
| 995 | let num_concurrent = 100; | ||
| 996 | let mut handles = vec![]; | ||
| 997 | |||
| 998 | for i in 0..num_concurrent { | ||
| 999 | let app = app.clone(); | ||
| 1000 | let handle = tokio::spawn(async move { | ||
| 1001 | let (announcement, state) = app.create_repo_with_state() | ||
| 1002 | .branch("main", &format!("commit-{}", i)) | ||
| 1003 | .build() | ||
| 1004 | .await; | ||
| 1005 | |||
| 1006 | app.git_push("main", &format!("commit-{}", i)).await | ||
| 1007 | }); | ||
| 1008 | handles.push(handle); | ||
| 1009 | } | ||
| 1010 | |||
| 1011 | let results = futures::future::join_all(handles).await; | ||
| 1012 | |||
| 1013 | // All should succeed | ||
| 1014 | for result in results { | ||
| 1015 | assert!(result.unwrap().success); | ||
| 1016 | } | ||
| 1017 | } | ||
| 1018 | 240 | ||
| 241 | ```rust | ||
| 1019 | #[tokio::test] | 242 | #[tokio::test] |
| 1020 | async fn test_event_ingestion_throughput() { | 243 | async fn test_something() { |
| 1021 | let app = test_app().await; | 244 | // Start fresh relay |
| 1022 | 245 | let relay = TestRelay::start().await; | |
| 1023 | let num_events = 1000; | ||
| 1024 | let start = Instant::now(); | ||
| 1025 | |||
| 1026 | for i in 0..num_events { | ||
| 1027 | let event = create_announcement() | ||
| 1028 | .with_identifier(&format!("repo-{}", i)) | ||
| 1029 | .build(); | ||
| 1030 | app.send_event(event).await.unwrap(); | ||
| 1031 | } | ||
| 1032 | 246 | ||
| 1033 | let duration = start.elapsed(); | 247 | // Run test |
| 1034 | let throughput = num_events as f64 / duration.as_secs_f64(); | 248 | let client = AuditClient::new(relay.url(), AuditConfig::isolated()).await?; |
| 249 | // ... test logic ... | ||
| 1035 | 250 | ||
| 1036 | println!("Event throughput: {:.2} events/sec", throughput); | 251 | // Cleanup |
| 1037 | assert!(throughput > 100.0, "Throughput too low"); | 252 | relay.stop().await; |
| 1038 | } | 253 | } |
| 1039 | ``` | 254 | ``` |
| 1040 | 255 | ||
| 1041 | ## Test Utilities | 256 | ### Macro-Based Test Generation |
| 1042 | 257 | ||
| 1043 | ### Test Fixtures | 258 | For test suites that follow the same pattern, use macros: |
| 1044 | 259 | ||
| 1045 | ```rust | 260 | ```rust |
| 1046 | // tests/common/fixtures.rs | 261 | macro_rules! isolated_test { |
| 1047 | 262 | ($test_name:ident) => { | |
| 1048 | pub struct TestEventBuilder { | 263 | #[tokio::test] |
| 1049 | kind: Kind, | 264 | async fn $test_name() { |
| 1050 | content: String, | 265 | let relay = TestRelay::start().await; |
| 1051 | tags: Vec<Tag>, | 266 | // ... standard setup and teardown ... |
| 1052 | keys: Option<Keys>, | ||
| 1053 | } | ||
| 1054 | |||
| 1055 | impl TestEventBuilder { | ||
| 1056 | pub fn announcement() -> Self { | ||
| 1057 | TestEventBuilder { | ||
| 1058 | kind: Kind::RepositoryAnnouncement, | ||
| 1059 | content: String::new(), | ||
| 1060 | tags: vec![], | ||
| 1061 | keys: None, | ||
| 1062 | } | 267 | } |
| 1063 | } | 268 | }; |
| 1064 | |||
| 1065 | pub fn with_identifier(mut self, id: &str) -> Self { | ||
| 1066 | self.tags.push(Tag::Identifier(id.to_string())); | ||
| 1067 | self | ||
| 1068 | } | ||
| 1069 | |||
| 1070 | pub fn with_clone_tag(mut self, url: &str) -> Self { | ||
| 1071 | self.tags.push(Tag::new("clone", vec![url])); | ||
| 1072 | self | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | pub async fn build(self) -> Event { | ||
| 1076 | let keys = self.keys.unwrap_or_else(|| Keys::generate()); | ||
| 1077 | EventBuilder::new(self.kind, self.content, self.tags) | ||
| 1078 | .to_event(&keys) | ||
| 1079 | .await | ||
| 1080 | .unwrap() | ||
| 1081 | } | ||
| 1082 | } | ||
| 1083 | ``` | ||
| 1084 | |||
| 1085 | ### Test Server | ||
| 1086 | |||
| 1087 | ```rust | ||
| 1088 | // tests/common/server.rs | ||
| 1089 | |||
| 1090 | pub struct TestServer { | ||
| 1091 | addr: SocketAddr, | ||
| 1092 | handle: JoinHandle<()>, | ||
| 1093 | } | ||
| 1094 | |||
| 1095 | impl TestServer { | ||
| 1096 | pub async fn start() -> Self { | ||
| 1097 | let config = Config { | ||
| 1098 | domain: "localhost:0".to_string(), | ||
| 1099 | git_data_path: TempDir::new().unwrap().into_path(), | ||
| 1100 | relay_data_path: TempDir::new().unwrap().into_path(), | ||
| 1101 | // ... other config | ||
| 1102 | }; | ||
| 1103 | |||
| 1104 | let app = create_app(config).await; | ||
| 1105 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
| 1106 | let addr = listener.local_addr().unwrap(); | ||
| 1107 | |||
| 1108 | let handle = tokio::spawn(async move { | ||
| 1109 | axum::serve(listener, app).await.unwrap(); | ||
| 1110 | }); | ||
| 1111 | |||
| 1112 | // Wait for server to be ready | ||
| 1113 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 1114 | |||
| 1115 | TestServer { addr, handle } | ||
| 1116 | } | ||
| 1117 | |||
| 1118 | pub fn url(&self) -> String { | ||
| 1119 | format!("http://{}", self.addr) | ||
| 1120 | } | ||
| 1121 | |||
| 1122 | pub fn ws_url(&self) -> String { | ||
| 1123 | format!("ws://{}", self.addr) | ||
| 1124 | } | ||
| 1125 | } | 269 | } |
| 1126 | ``` | ||
| 1127 | 270 | ||
| 1128 | ## CI/CD Integration | 271 | isolated_test!(test_websocket_connection); |
| 1129 | 272 | isolated_test!(test_send_receive_event); | |
| 1130 | ### GitHub Actions Workflow | ||
| 1131 | |||
| 1132 | ```yaml | ||
| 1133 | # .github/workflows/test.yml | ||
| 1134 | |||
| 1135 | name: Test | ||
| 1136 | |||
| 1137 | on: [push, pull_request] | ||
| 1138 | |||
| 1139 | jobs: | ||
| 1140 | unit-tests: | ||
| 1141 | runs-on: ubuntu-latest | ||
| 1142 | steps: | ||
| 1143 | - uses: actions/checkout@v3 | ||
| 1144 | - uses: actions-rs/toolchain@v1 | ||
| 1145 | with: | ||
| 1146 | toolchain: stable | ||
| 1147 | - name: Run unit tests | ||
| 1148 | run: cargo test --lib | ||
| 1149 | |||
| 1150 | integration-tests: | ||
| 1151 | runs-on: ubuntu-latest | ||
| 1152 | steps: | ||
| 1153 | - uses: actions/checkout@v3 | ||
| 1154 | - uses: actions-rs/toolchain@v1 | ||
| 1155 | with: | ||
| 1156 | toolchain: stable | ||
| 1157 | - name: Install Git | ||
| 1158 | run: sudo apt-get install -y git | ||
| 1159 | - name: Run integration tests | ||
| 1160 | run: cargo test --test '*' | ||
| 1161 | |||
| 1162 | compliance-tests: | ||
| 1163 | runs-on: ubuntu-latest | ||
| 1164 | steps: | ||
| 1165 | - uses: actions/checkout@v3 | ||
| 1166 | - uses: actions-rs/toolchain@v1 | ||
| 1167 | with: | ||
| 1168 | toolchain: stable | ||
| 1169 | - name: Run GRASP-01 compliance tests | ||
| 1170 | run: cargo test --test compliance | ||
| 1171 | - name: Generate compliance report | ||
| 1172 | run: cargo run --example compliance-report > compliance-report.txt | ||
| 1173 | - name: Upload compliance report | ||
| 1174 | uses: actions/upload-artifact@v3 | ||
| 1175 | with: | ||
| 1176 | name: compliance-report | ||
| 1177 | path: compliance-report.txt | ||
| 1178 | ``` | 273 | ``` |
| 1179 | 274 | ||
| 1180 | ## Test Coverage | 275 | ## Coverage Targets |
| 1181 | 276 | ||
| 1182 | ### Target Coverage | 277 | | Test Type | Coverage Target | |
| 278 | |-----------|-----------------| | ||
| 279 | | Unit Tests | >80% line coverage of `src/` | | ||
| 280 | | Integration Tests | All critical user paths | | ||
| 281 | | GRASP-01 Compliance | 100% of MUST requirements | | ||
| 1183 | 282 | ||
| 1184 | - **Unit Tests**: >80% line coverage | 283 | ## CI/CD Integration |
| 1185 | - **Integration Tests**: All critical paths | ||
| 1186 | - **Compliance Tests**: 100% of GRASP-01 requirements | ||
| 1187 | - **E2E Tests**: Key user workflows | ||
| 1188 | 284 | ||
| 1189 | ### Measuring Coverage | 285 | ### Running All Tests |
| 1190 | 286 | ||
| 1191 | ```bash | 287 | ```bash |
| 1192 | # Install tarpaulin | 288 | # Unit tests (fast, no external dependencies) |
| 1193 | cargo install cargo-tarpaulin | 289 | cargo test --lib |
| 1194 | 290 | ||
| 1195 | # Run with coverage | 291 | # Integration tests (requires relay binary built) |
| 1196 | cargo tarpaulin --out Html --output-dir coverage | 292 | cargo build --release |
| 293 | cargo test --test '*' | ||
| 1197 | 294 | ||
| 1198 | # View report | 295 | # Compliance tests against ngit-relay reference |
| 1199 | open coverage/index.html | 296 | cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test |
| 1200 | ``` | ||
| 1201 | |||
| 1202 | ## Documentation Testing | ||
| 1203 | |||
| 1204 | ### Doc Tests | ||
| 1205 | |||
| 1206 | ```rust | ||
| 1207 | /// Parse a pkt-line from Git protocol | ||
| 1208 | /// | ||
| 1209 | /// # Examples | ||
| 1210 | /// | ||
| 1211 | /// ``` | ||
| 1212 | /// use ngit_grasp::git::parse_pkt_line; | ||
| 1213 | /// | ||
| 1214 | /// let data = b"0006a\n"; | ||
| 1215 | /// let (length, payload) = parse_pkt_line(data).unwrap(); | ||
| 1216 | /// assert_eq!(length, 6); | ||
| 1217 | /// assert_eq!(payload, b"a\n"); | ||
| 1218 | /// ``` | ||
| 1219 | pub fn parse_pkt_line(data: &[u8]) -> Result<(usize, &[u8])> { | ||
| 1220 | // implementation | ||
| 1221 | } | ||
| 1222 | ``` | 297 | ``` |
| 1223 | 298 | ||
| 1224 | ## Summary | 299 | ## Summary |
| 1225 | 300 | ||
| 1226 | This comprehensive test strategy ensures: | 301 | | What | Where | Purpose | |
| 302 | |------|-------|---------| | ||
| 303 | | Unit tests | `src/**/tests` modules | Test individual functions | | ||
| 304 | | Integration tests | `tests/*.rs` | Test ngit-grasp as a whole | | ||
| 305 | | TestRelay fixture | [`tests/common/relay.rs`](tests/common/relay.rs) | Manage relay lifecycle | | ||
| 306 | | GRASP audit library | `grasp-audit/` | Reusable compliance testing | | ||
| 307 | | GRASP-01 specs | [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/) | Spec requirement tests | | ||
| 1227 | 308 | ||
| 1228 | 1. **Spec Compliance**: Every GRASP requirement has a corresponding test | 309 | ## Related Documentation |
| 1229 | 2. **Reusability**: Compliance tests can validate any GRASP implementation | ||
| 1230 | 3. **Clear Failures**: Test failures cite exact spec lines | ||
| 1231 | 4. **Comprehensive Coverage**: Unit, integration, compliance, and E2E tests | ||
| 1232 | 5. **Maintainability**: Tests mirror spec structure for easy updates | ||
| 1233 | 310 | ||
| 1234 | The compliance testing tool is a standalone crate that can be: | 311 | - [Architecture](../explanation/architecture.md) - System design |
| 1235 | - Used by ngit-grasp for self-validation | 312 | - [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons |
| 1236 | - Published for other GRASP implementations to use | 313 | - [GRASP Audit Learnings](../learnings/grasp-audit.md) - Audit tool patterns \ No newline at end of file |
| 1237 | - Updated as new GRASP specs are released (GRASP-02, GRASP-05) | ||
| 1238 | - Run in CI/CD for continuous compliance verification | ||