diff options
Diffstat (limited to 'docs/reference')
| -rw-r--r-- | docs/reference/README.md | 201 | ||||
| -rw-r--r-- | docs/reference/configuration.md | 434 | ||||
| -rw-r--r-- | docs/reference/git-protocol.md | 435 | ||||
| -rw-r--r-- | docs/reference/test-strategy.md | 1238 |
4 files changed, 2308 insertions, 0 deletions
diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 0000000..96fc5ed --- /dev/null +++ b/docs/reference/README.md | |||
| @@ -0,0 +1,201 @@ | |||
| 1 | # Reference | ||
| 2 | |||
| 3 | **Information-oriented documentation** - Technical details and specifications. | ||
| 4 | |||
| 5 | --- | ||
| 6 | |||
| 7 | ## What Is Reference Documentation? | ||
| 8 | |||
| 9 | Reference documentation provides **factual, technical information** that you look up when needed. | ||
| 10 | |||
| 11 | **Characteristics:** | ||
| 12 | - ✅ Information-oriented (facts and data) | ||
| 13 | - ✅ Comprehensive and accurate | ||
| 14 | - ✅ Structured for lookup | ||
| 15 | - ✅ Dry and to-the-point | ||
| 16 | - ✅ Maintained as code changes | ||
| 17 | |||
| 18 | **Not reference:** | ||
| 19 | - ❌ Learning materials (those are Tutorials) | ||
| 20 | - ❌ Problem-solving guides (those are How-To) | ||
| 21 | - ❌ Conceptual explanations (those are Explanation) | ||
| 22 | |||
| 23 | --- | ||
| 24 | |||
| 25 | ## Available Reference Documentation | ||
| 26 | |||
| 27 | ### [Configuration](configuration.md) | ||
| 28 | **Complete reference for all configuration options** | ||
| 29 | |||
| 30 | **Contents:** | ||
| 31 | - Environment variables | ||
| 32 | - Configuration file format | ||
| 33 | - Validation rules | ||
| 34 | - Examples for development/production/testing | ||
| 35 | |||
| 36 | **Use when:** You need to know what a config option does or what values are valid | ||
| 37 | |||
| 38 | --- | ||
| 39 | |||
| 40 | ### [Git Protocol](git-protocol.md) | ||
| 41 | **Git Smart HTTP protocol specification** | ||
| 42 | |||
| 43 | **Contents:** | ||
| 44 | - Protocol overview | ||
| 45 | - Pkt-line format | ||
| 46 | - Request/response structure | ||
| 47 | - Reference updates format | ||
| 48 | - Parsing examples | ||
| 49 | |||
| 50 | **Use when:** You need to understand Git HTTP internals | ||
| 51 | |||
| 52 | --- | ||
| 53 | |||
| 54 | ### [Test Strategy](test-strategy.md) | ||
| 55 | **Testing approach and compliance framework** | ||
| 56 | |||
| 57 | **Contents:** | ||
| 58 | - Test categories (unit, integration, compliance) | ||
| 59 | - GRASP compliance requirements | ||
| 60 | - Test isolation strategy | ||
| 61 | - Running tests | ||
| 62 | - Coverage requirements | ||
| 63 | |||
| 64 | **Use when:** You're writing tests or need to understand test structure | ||
| 65 | |||
| 66 | --- | ||
| 67 | |||
| 68 | ## Planned Reference Documentation | ||
| 69 | |||
| 70 | ### GRASP Protocol | ||
| 71 | **Status:** 🔜 Planned | ||
| 72 | |||
| 73 | **Contents:** | ||
| 74 | - GRASP-01 requirements | ||
| 75 | - GRASP-02 (Proactive Sync) | ||
| 76 | - GRASP-05 (Archive) | ||
| 77 | - Event formats | ||
| 78 | - Validation rules | ||
| 79 | |||
| 80 | --- | ||
| 81 | |||
| 82 | ### API Reference | ||
| 83 | **Status:** 🔜 Planned (waiting for main server) | ||
| 84 | |||
| 85 | **Contents:** | ||
| 86 | - HTTP endpoints | ||
| 87 | - Request/response formats | ||
| 88 | - Error codes | ||
| 89 | - Authentication | ||
| 90 | - Rate limiting | ||
| 91 | |||
| 92 | --- | ||
| 93 | |||
| 94 | ### nostr-sdk Upgrade Guide | ||
| 95 | **Status:** 🔜 Planned | ||
| 96 | |||
| 97 | **Contents:** | ||
| 98 | - Version compatibility matrix | ||
| 99 | - Breaking changes by version | ||
| 100 | - Migration examples | ||
| 101 | - Common patterns | ||
| 102 | |||
| 103 | --- | ||
| 104 | |||
| 105 | ### Event Formats | ||
| 106 | **Status:** 🔜 Planned | ||
| 107 | |||
| 108 | **Contents:** | ||
| 109 | - NIP-34 repository announcements (kind 30317) | ||
| 110 | - NIP-34 state events (kind 30318) | ||
| 111 | - Custom tags | ||
| 112 | - Validation rules | ||
| 113 | |||
| 114 | --- | ||
| 115 | |||
| 116 | ### CLI Reference | ||
| 117 | **Status:** 🔜 Planned | ||
| 118 | |||
| 119 | **Contents:** | ||
| 120 | - Command-line arguments | ||
| 121 | - Subcommands | ||
| 122 | - Environment variables | ||
| 123 | - Exit codes | ||
| 124 | |||
| 125 | --- | ||
| 126 | |||
| 127 | ## How to Use Reference Documentation | ||
| 128 | |||
| 129 | 1. **Know what you're looking for** - Reference is for lookup, not learning | ||
| 130 | 2. **Use search or table of contents** - Find the specific detail you need | ||
| 131 | 3. **Check version** - Ensure docs match your version | ||
| 132 | 4. **Verify with code** - Reference should match implementation | ||
| 133 | |||
| 134 | **Not sure if this is what you need?** | ||
| 135 | - New to the topic? → [Tutorials](../tutorials/) | ||
| 136 | - Trying to solve a problem? → [How-To Guides](../how-to/) | ||
| 137 | - Want to understand concepts? → [Explanation](../explanation/) | ||
| 138 | |||
| 139 | --- | ||
| 140 | |||
| 141 | ## Contributing Reference Documentation | ||
| 142 | |||
| 143 | When writing reference documentation: | ||
| 144 | |||
| 145 | **DO:** | ||
| 146 | - ✅ Be accurate and complete | ||
| 147 | - ✅ Use consistent structure | ||
| 148 | - ✅ Include all options/parameters | ||
| 149 | - ✅ Provide examples | ||
| 150 | - ✅ Update when code changes | ||
| 151 | - ✅ Use tables for structured data | ||
| 152 | |||
| 153 | **DON'T:** | ||
| 154 | - ❌ Explain concepts (link to Explanation) | ||
| 155 | - ❌ Provide tutorials (link to Tutorials) | ||
| 156 | - ❌ Solve problems (link to How-To) | ||
| 157 | - ❌ Include opinions or recommendations | ||
| 158 | |||
| 159 | **Template:** | ||
| 160 | ```markdown | ||
| 161 | # Reference: [Topic] | ||
| 162 | |||
| 163 | **Purpose:** [What this reference covers] | ||
| 164 | **Audience:** [Who needs this information] | ||
| 165 | |||
| 166 | --- | ||
| 167 | |||
| 168 | ## Overview | ||
| 169 | |||
| 170 | [Brief description of what's being documented] | ||
| 171 | |||
| 172 | --- | ||
| 173 | |||
| 174 | ## [Section 1] | ||
| 175 | |||
| 176 | ### [Item] | ||
| 177 | |||
| 178 | **Description:** [What it is/does] | ||
| 179 | **Type:** [Data type] | ||
| 180 | **Default:** [Default value] | ||
| 181 | **Required:** [Yes/No] | ||
| 182 | |||
| 183 | **Examples:** | ||
| 184 | \`\`\` | ||
| 185 | [Example usage] | ||
| 186 | \`\`\` | ||
| 187 | |||
| 188 | **Notes:** | ||
| 189 | - [Important details] | ||
| 190 | |||
| 191 | --- | ||
| 192 | |||
| 193 | ## Related Documentation | ||
| 194 | - [Links to relevant docs] | ||
| 195 | ``` | ||
| 196 | |||
| 197 | See [Diátaxis: Reference](https://diataxis.fr/reference/) for detailed guidance. | ||
| 198 | |||
| 199 | --- | ||
| 200 | |||
| 201 | *Part of the [ngit-grasp documentation](../README.md) using the [Diátaxis](https://diataxis.fr/) framework.* | ||
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..fc7bbe0 --- /dev/null +++ b/docs/reference/configuration.md | |||
| @@ -0,0 +1,434 @@ | |||
| 1 | # Reference: Configuration | ||
| 2 | |||
| 3 | **Purpose:** Complete reference for all ngit-grasp configuration options | ||
| 4 | **Audience:** Operators and developers | ||
| 5 | |||
| 6 | --- | ||
| 7 | |||
| 8 | ## Configuration Methods | ||
| 9 | |||
| 10 | ngit-grasp can be configured via: | ||
| 11 | |||
| 12 | 1. **Environment variables** (recommended for deployment) | ||
| 13 | 2. **`.env` file** (recommended for development) | ||
| 14 | 3. **Command-line arguments** (planned, not yet implemented) | ||
| 15 | |||
| 16 | Configuration is loaded at startup and validated before the server starts. | ||
| 17 | |||
| 18 | --- | ||
| 19 | |||
| 20 | ## Environment Variables | ||
| 21 | |||
| 22 | ### Server Configuration | ||
| 23 | |||
| 24 | #### `NGIT_BIND_ADDRESS` | ||
| 25 | |||
| 26 | **Description:** Address and port for the HTTP server to bind to | ||
| 27 | **Type:** String (IP:PORT format) | ||
| 28 | **Default:** `127.0.0.1:8080` | ||
| 29 | **Required:** No | ||
| 30 | |||
| 31 | **Examples:** | ||
| 32 | ```bash | ||
| 33 | # Localhost only (development) | ||
| 34 | NGIT_BIND_ADDRESS=127.0.0.1:8080 | ||
| 35 | |||
| 36 | # All interfaces (production) | ||
| 37 | NGIT_BIND_ADDRESS=0.0.0.0:8080 | ||
| 38 | |||
| 39 | # IPv6 | ||
| 40 | NGIT_BIND_ADDRESS=[::1]:8080 | ||
| 41 | |||
| 42 | # Custom port | ||
| 43 | NGIT_BIND_ADDRESS=127.0.0.1:3000 | ||
| 44 | ``` | ||
| 45 | |||
| 46 | **Notes:** | ||
| 47 | - Use `127.0.0.1` for local development | ||
| 48 | - Use `0.0.0.0` for production (behind reverse proxy) | ||
| 49 | - Ensure firewall rules allow the port | ||
| 50 | |||
| 51 | --- | ||
| 52 | |||
| 53 | #### `NGIT_DOMAIN` | ||
| 54 | |||
| 55 | **Description:** Public domain name for this GRASP instance | ||
| 56 | **Type:** String (domain name) | ||
| 57 | **Default:** None | ||
| 58 | **Required:** Yes | ||
| 59 | |||
| 60 | **Examples:** | ||
| 61 | ```bash | ||
| 62 | NGIT_DOMAIN=gitnostr.com | ||
| 63 | NGIT_DOMAIN=git.example.org | ||
| 64 | NGIT_DOMAIN=localhost:8080 # Development only | ||
| 65 | ``` | ||
| 66 | |||
| 67 | **Used for:** | ||
| 68 | - NIP-11 relay information document | ||
| 69 | - Generating repository URLs | ||
| 70 | - CORS configuration | ||
| 71 | - Webhook URLs (future) | ||
| 72 | |||
| 73 | **Notes:** | ||
| 74 | - Must be accessible from the internet for production | ||
| 75 | - Include port if non-standard (e.g., `localhost:8080`) | ||
| 76 | - Used in repository clone URLs: `https://{NGIT_DOMAIN}/{npub}/{repo}.git` | ||
| 77 | |||
| 78 | --- | ||
| 79 | |||
| 80 | ### Nostr Relay Configuration | ||
| 81 | |||
| 82 | #### `NGIT_OWNER_NPUB` | ||
| 83 | |||
| 84 | **Description:** Nostr public key (npub format) of the relay operator | ||
| 85 | **Type:** String (npub1... format) | ||
| 86 | **Default:** None | ||
| 87 | **Required:** Yes | ||
| 88 | |||
| 89 | **Examples:** | ||
| 90 | ```bash | ||
| 91 | NGIT_OWNER_NPUB=npub1alice... | ||
| 92 | ``` | ||
| 93 | |||
| 94 | **Used for:** | ||
| 95 | - NIP-11 relay information document | ||
| 96 | - Contact information | ||
| 97 | - Administrative operations (future) | ||
| 98 | |||
| 99 | **Notes:** | ||
| 100 | - Must be valid npub format (starts with `npub1`) | ||
| 101 | - Can be generated with Nostr tools | ||
| 102 | - Publicly visible in relay metadata | ||
| 103 | |||
| 104 | --- | ||
| 105 | |||
| 106 | #### `NGIT_RELAY_NAME` | ||
| 107 | |||
| 108 | **Description:** Human-readable name for this relay | ||
| 109 | **Type:** String | ||
| 110 | **Default:** `"ngit-grasp relay"` | ||
| 111 | **Required:** No | ||
| 112 | |||
| 113 | **Examples:** | ||
| 114 | ```bash | ||
| 115 | NGIT_RELAY_NAME="GitNostr Community Relay" | ||
| 116 | NGIT_RELAY_NAME="Alice's GRASP Server" | ||
| 117 | ``` | ||
| 118 | |||
| 119 | **Used for:** | ||
| 120 | - NIP-11 relay information document | ||
| 121 | - Client display | ||
| 122 | - Relay discovery | ||
| 123 | |||
| 124 | --- | ||
| 125 | |||
| 126 | #### `NGIT_RELAY_DESCRIPTION` | ||
| 127 | |||
| 128 | **Description:** Description of this relay's purpose and policies | ||
| 129 | **Type:** String | ||
| 130 | **Default:** `"A GRASP-compliant Git relay"` | ||
| 131 | **Required:** No | ||
| 132 | |||
| 133 | **Examples:** | ||
| 134 | ```bash | ||
| 135 | NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects" | ||
| 136 | NGIT_RELAY_DESCRIPTION="Private relay for ACME Corp repositories" | ||
| 137 | ``` | ||
| 138 | |||
| 139 | **Used for:** | ||
| 140 | - NIP-11 relay information document | ||
| 141 | - User information | ||
| 142 | - Relay selection | ||
| 143 | |||
| 144 | --- | ||
| 145 | |||
| 146 | ### Storage Configuration | ||
| 147 | |||
| 148 | #### `NGIT_GIT_DATA_PATH` | ||
| 149 | |||
| 150 | **Description:** Directory path for storing Git repositories | ||
| 151 | **Type:** String (filesystem path) | ||
| 152 | **Default:** `./data/git` | ||
| 153 | **Required:** No | ||
| 154 | |||
| 155 | **Examples:** | ||
| 156 | ```bash | ||
| 157 | # Relative path (development) | ||
| 158 | NGIT_GIT_DATA_PATH=./data/git | ||
| 159 | |||
| 160 | # Absolute path (production) | ||
| 161 | NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git | ||
| 162 | |||
| 163 | # Custom location | ||
| 164 | NGIT_GIT_DATA_PATH=/mnt/storage/git-repos | ||
| 165 | ``` | ||
| 166 | |||
| 167 | **Storage structure:** | ||
| 168 | ``` | ||
| 169 | {NGIT_GIT_DATA_PATH}/ | ||
| 170 | ├── {npub1}/ | ||
| 171 | │ ├── {repo1}.git/ | ||
| 172 | │ │ ├── objects/ | ||
| 173 | │ │ ├── refs/ | ||
| 174 | │ │ └── ... | ||
| 175 | │ └── {repo2}.git/ | ||
| 176 | └── {npub2}/ | ||
| 177 | └── ... | ||
| 178 | ``` | ||
| 179 | |||
| 180 | **Notes:** | ||
| 181 | - Directory must be writable by ngit-grasp process | ||
| 182 | - Ensure sufficient disk space | ||
| 183 | - Consider backup strategy | ||
| 184 | - Use fast storage for better performance | ||
| 185 | |||
| 186 | --- | ||
| 187 | |||
| 188 | #### `NGIT_RELAY_DATA_PATH` | ||
| 189 | |||
| 190 | **Description:** Directory path for storing Nostr events and relay data | ||
| 191 | **Type:** String (filesystem path) | ||
| 192 | **Default:** `./data/relay` | ||
| 193 | **Required:** No | ||
| 194 | |||
| 195 | **Examples:** | ||
| 196 | ```bash | ||
| 197 | # Relative path (development) | ||
| 198 | NGIT_RELAY_DATA_PATH=./data/relay | ||
| 199 | |||
| 200 | # Absolute path (production) | ||
| 201 | NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay | ||
| 202 | |||
| 203 | # Separate disk | ||
| 204 | NGIT_RELAY_DATA_PATH=/mnt/ssd/relay-data | ||
| 205 | ``` | ||
| 206 | |||
| 207 | **Storage structure:** | ||
| 208 | ``` | ||
| 209 | {NGIT_RELAY_DATA_PATH}/ | ||
| 210 | ├── events/ | ||
| 211 | │ └── {event-id}.json | ||
| 212 | ├── indexes/ | ||
| 213 | │ ├── by-kind/ | ||
| 214 | │ ├── by-author/ | ||
| 215 | │ └── by-tag/ | ||
| 216 | └── metadata/ | ||
| 217 | ``` | ||
| 218 | |||
| 219 | **Notes:** | ||
| 220 | - Directory must be writable | ||
| 221 | - Consider SSD for better query performance | ||
| 222 | - Size grows with event count | ||
| 223 | - Implement retention policy for production | ||
| 224 | |||
| 225 | --- | ||
| 226 | |||
| 227 | ### Logging Configuration | ||
| 228 | |||
| 229 | #### `RUST_LOG` | ||
| 230 | |||
| 231 | **Description:** Logging level and filters (standard Rust environment variable) | ||
| 232 | **Type:** String (log level or filter) | ||
| 233 | **Default:** `info` | ||
| 234 | **Required:** No | ||
| 235 | |||
| 236 | **Examples:** | ||
| 237 | ```bash | ||
| 238 | # Simple levels | ||
| 239 | RUST_LOG=error # Errors only | ||
| 240 | RUST_LOG=warn # Warnings and errors | ||
| 241 | RUST_LOG=info # Info, warnings, errors | ||
| 242 | RUST_LOG=debug # Debug and above | ||
| 243 | RUST_LOG=trace # Everything | ||
| 244 | |||
| 245 | # Module-specific | ||
| 246 | RUST_LOG=ngit_grasp=debug,actix_web=info | ||
| 247 | |||
| 248 | # Complex filters | ||
| 249 | RUST_LOG=debug,hyper=info,tokio=warn | ||
| 250 | ``` | ||
| 251 | |||
| 252 | **Log levels (most to least verbose):** | ||
| 253 | 1. `trace` - Very detailed, performance impact | ||
| 254 | 2. `debug` - Detailed debugging information | ||
| 255 | 3. `info` - General information (default) | ||
| 256 | 4. `warn` - Warnings about potential issues | ||
| 257 | 5. `error` - Errors only | ||
| 258 | |||
| 259 | **Production recommendation:** | ||
| 260 | ```bash | ||
| 261 | RUST_LOG=info,ngit_grasp=debug | ||
| 262 | ``` | ||
| 263 | |||
| 264 | --- | ||
| 265 | |||
| 266 | ### Security Configuration (Planned) | ||
| 267 | |||
| 268 | #### `NGIT_AUTH_REQUIRED` | ||
| 269 | |||
| 270 | **Description:** Require authentication for all operations | ||
| 271 | **Type:** Boolean | ||
| 272 | **Default:** `false` | ||
| 273 | **Status:** 🔜 Planned | ||
| 274 | |||
| 275 | **Examples:** | ||
| 276 | ```bash | ||
| 277 | NGIT_AUTH_REQUIRED=true # Require auth | ||
| 278 | NGIT_AUTH_REQUIRED=false # Public relay | ||
| 279 | ``` | ||
| 280 | |||
| 281 | --- | ||
| 282 | |||
| 283 | #### `NGIT_RATE_LIMIT_ENABLED` | ||
| 284 | |||
| 285 | **Description:** Enable rate limiting | ||
| 286 | **Type:** Boolean | ||
| 287 | **Default:** `true` | ||
| 288 | **Status:** 🔜 Planned | ||
| 289 | |||
| 290 | **Examples:** | ||
| 291 | ```bash | ||
| 292 | NGIT_RATE_LIMIT_ENABLED=true | ||
| 293 | NGIT_RATE_LIMIT_ENABLED=false | ||
| 294 | ``` | ||
| 295 | |||
| 296 | --- | ||
| 297 | |||
| 298 | ## Configuration File (.env) | ||
| 299 | |||
| 300 | For development, create a `.env` file in the project root: | ||
| 301 | |||
| 302 | ```bash | ||
| 303 | # .env file example | ||
| 304 | NGIT_DOMAIN=localhost:8080 | ||
| 305 | NGIT_OWNER_NPUB=npub1alice... | ||
| 306 | NGIT_RELAY_NAME="Development Relay" | ||
| 307 | NGIT_RELAY_DESCRIPTION="Local development instance" | ||
| 308 | NGIT_GIT_DATA_PATH=./data/git | ||
| 309 | NGIT_RELAY_DATA_PATH=./data/relay | ||
| 310 | NGIT_BIND_ADDRESS=127.0.0.1:8080 | ||
| 311 | RUST_LOG=debug | ||
| 312 | ``` | ||
| 313 | |||
| 314 | **Notes:** | ||
| 315 | - Never commit `.env` to version control | ||
| 316 | - Use `.env.example` as a template | ||
| 317 | - Environment variables override `.env` values | ||
| 318 | |||
| 319 | --- | ||
| 320 | |||
| 321 | ## Validation | ||
| 322 | |||
| 323 | Configuration is validated at startup: | ||
| 324 | |||
| 325 | ```rust | ||
| 326 | // Example validation errors: | ||
| 327 | Error: Invalid configuration | ||
| 328 | - NGIT_DOMAIN is required | ||
| 329 | - NGIT_OWNER_NPUB must start with 'npub1' | ||
| 330 | - NGIT_GIT_DATA_PATH is not writable | ||
| 331 | ``` | ||
| 332 | |||
| 333 | **Validation checks:** | ||
| 334 | - Required fields are present | ||
| 335 | - Values have correct format | ||
| 336 | - Paths are accessible and writable | ||
| 337 | - Ports are available | ||
| 338 | - npub keys are valid | ||
| 339 | |||
| 340 | --- | ||
| 341 | |||
| 342 | ## Production Configuration Example | ||
| 343 | |||
| 344 | ```bash | ||
| 345 | # Production .env | ||
| 346 | NGIT_DOMAIN=gitnostr.com | ||
| 347 | NGIT_OWNER_NPUB=npub1alice... | ||
| 348 | NGIT_RELAY_NAME="GitNostr Public Relay" | ||
| 349 | NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects" | ||
| 350 | NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git | ||
| 351 | NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay | ||
| 352 | NGIT_BIND_ADDRESS=0.0.0.0:8080 | ||
| 353 | RUST_LOG=info,ngit_grasp=debug | ||
| 354 | ``` | ||
| 355 | |||
| 356 | **Additional production considerations:** | ||
| 357 | - Use reverse proxy (nginx, Caddy) for HTTPS | ||
| 358 | - Set up log rotation | ||
| 359 | - Configure monitoring | ||
| 360 | - Implement backup strategy | ||
| 361 | - Use dedicated user account | ||
| 362 | - Set file permissions properly | ||
| 363 | |||
| 364 | --- | ||
| 365 | |||
| 366 | ## Development Configuration Example | ||
| 367 | |||
| 368 | ```bash | ||
| 369 | # Development .env | ||
| 370 | NGIT_DOMAIN=localhost:8080 | ||
| 371 | NGIT_OWNER_NPUB=npub1test... | ||
| 372 | NGIT_RELAY_NAME="Dev Relay" | ||
| 373 | NGIT_RELAY_DESCRIPTION="Local development" | ||
| 374 | NGIT_GIT_DATA_PATH=./data/git | ||
| 375 | NGIT_RELAY_DATA_PATH=./data/relay | ||
| 376 | NGIT_BIND_ADDRESS=127.0.0.1:8080 | ||
| 377 | RUST_LOG=debug | ||
| 378 | ``` | ||
| 379 | |||
| 380 | --- | ||
| 381 | |||
| 382 | ## Testing Configuration Example | ||
| 383 | |||
| 384 | ```bash | ||
| 385 | # Testing .env | ||
| 386 | NGIT_DOMAIN=localhost:9999 | ||
| 387 | NGIT_OWNER_NPUB=npub1test... | ||
| 388 | NGIT_RELAY_NAME="Test Relay" | ||
| 389 | NGIT_RELAY_DESCRIPTION="Automated testing" | ||
| 390 | NGIT_GIT_DATA_PATH=/tmp/ngit-test/git | ||
| 391 | NGIT_RELAY_DATA_PATH=/tmp/ngit-test/relay | ||
| 392 | NGIT_BIND_ADDRESS=127.0.0.1:9999 | ||
| 393 | RUST_LOG=debug | ||
| 394 | ``` | ||
| 395 | |||
| 396 | **Testing notes:** | ||
| 397 | - Use temporary directories | ||
| 398 | - Use non-standard ports | ||
| 399 | - Clean up after tests | ||
| 400 | - Isolate from development data | ||
| 401 | |||
| 402 | --- | ||
| 403 | |||
| 404 | ## Configuration Priority | ||
| 405 | |||
| 406 | When multiple configuration sources exist: | ||
| 407 | |||
| 408 | 1. **Command-line arguments** (highest priority, planned) | ||
| 409 | 2. **Environment variables** | ||
| 410 | 3. **`.env` file** | ||
| 411 | 4. **Default values** (lowest priority) | ||
| 412 | |||
| 413 | **Example:** | ||
| 414 | ```bash | ||
| 415 | # .env file | ||
| 416 | NGIT_BIND_ADDRESS=127.0.0.1:8080 | ||
| 417 | |||
| 418 | # Environment variable (overrides .env) | ||
| 419 | NGIT_BIND_ADDRESS=0.0.0.0:3000 cargo run | ||
| 420 | |||
| 421 | # Result: binds to 0.0.0.0:3000 | ||
| 422 | ``` | ||
| 423 | |||
| 424 | --- | ||
| 425 | |||
| 426 | ## Related Documentation | ||
| 427 | |||
| 428 | - [Deployment How-To](../how-to/deploy.md) - Production deployment | ||
| 429 | - [Getting Started Tutorial](../tutorials/getting-started.md) - Initial setup | ||
| 430 | - [Architecture Overview](../explanation/architecture.md) - System design | ||
| 431 | |||
| 432 | --- | ||
| 433 | |||
| 434 | *Part of the [ngit-grasp reference documentation](./)* | ||
diff --git a/docs/reference/git-protocol.md b/docs/reference/git-protocol.md new file mode 100644 index 0000000..172a7bc --- /dev/null +++ b/docs/reference/git-protocol.md | |||
| @@ -0,0 +1,435 @@ | |||
| 1 | # Git Smart HTTP Protocol Reference | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. | ||
| 6 | |||
| 7 | ## Protocol Flow | ||
| 8 | |||
| 9 | ### Clone/Fetch (Upload Pack) | ||
| 10 | |||
| 11 | ``` | ||
| 12 | 1. Client → GET /repo.git/info/refs?service=git-upload-pack | ||
| 13 | Server → 200 OK with pack advertisement | ||
| 14 | |||
| 15 | 2. Client → POST /repo.git/git-upload-pack | ||
| 16 | Body: want/have negotiation | ||
| 17 | Server → 200 OK with pack stream | ||
| 18 | ``` | ||
| 19 | |||
| 20 | **Authorization**: Not needed for public repositories. For GRASP-01, all repos are public. | ||
| 21 | |||
| 22 | ### Push (Receive Pack) | ||
| 23 | |||
| 24 | ``` | ||
| 25 | 1. Client → GET /repo.git/info/refs?service=git-receive-pack | ||
| 26 | Server → 200 OK with ref advertisement | ||
| 27 | |||
| 28 | 2. Client → POST /repo.git/git-receive-pack | ||
| 29 | Body: ref updates + pack data | ||
| 30 | Server → 200 OK with status | ||
| 31 | ``` | ||
| 32 | |||
| 33 | **Authorization**: THIS IS WHERE WE VALIDATE! Step 2 is where inline auth happens. | ||
| 34 | |||
| 35 | ## Receive Pack Request Format | ||
| 36 | |||
| 37 | The POST body to `git-receive-pack` has this structure: | ||
| 38 | |||
| 39 | ``` | ||
| 40 | [ref-updates] | ||
| 41 | [pack-data] | ||
| 42 | ``` | ||
| 43 | |||
| 44 | ### Ref Updates Format | ||
| 45 | |||
| 46 | Each ref update is in **pkt-line** format: | ||
| 47 | |||
| 48 | ``` | ||
| 49 | <4-byte-length><old-oid> <new-oid> <ref-name>\0<capabilities>\n | ||
| 50 | <4-byte-length><old-oid> <new-oid> <ref-name>\n | ||
| 51 | ... | ||
| 52 | 0000 | ||
| 53 | ``` | ||
| 54 | |||
| 55 | **Example** (hex representation): | ||
| 56 | |||
| 57 | ``` | ||
| 58 | 00a20000000000000000000000000000000000000000 a1b2c3d4e5f6... refs/heads/main\0 report-status side-band-64k | ||
| 59 | 003f0000000000000000000000000000000000000000 f6e5d4c3b2a1... refs/heads/dev\n | ||
| 60 | 0000 | ||
| 61 | ``` | ||
| 62 | |||
| 63 | ### Pkt-line Format | ||
| 64 | |||
| 65 | A pkt-line is: | ||
| 66 | - 4 hex digits: length of entire line (including the 4 digits) | ||
| 67 | - Payload data | ||
| 68 | - `0000` = flush packet (end of section) | ||
| 69 | |||
| 70 | **Length calculation**: | ||
| 71 | ``` | ||
| 72 | length = 4 (for length itself) + payload.len() | ||
| 73 | ``` | ||
| 74 | |||
| 75 | **Examples**: | ||
| 76 | ``` | ||
| 77 | "0006a\n" → length=6, payload="a\n" | ||
| 78 | "0000" → flush packet | ||
| 79 | "000bfoobar\n" → length=11, payload="foobar\n" | ||
| 80 | ``` | ||
| 81 | |||
| 82 | ### Parsing Ref Updates | ||
| 83 | |||
| 84 | ```rust | ||
| 85 | pub struct RefUpdate { | ||
| 86 | pub old_oid: String, // 40 hex chars | ||
| 87 | pub new_oid: String, // 40 hex chars | ||
| 88 | pub ref_name: String, // e.g., "refs/heads/main" | ||
| 89 | } | ||
| 90 | |||
| 91 | pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> { | ||
| 92 | let mut updates = Vec::new(); | ||
| 93 | let mut offset = 0; | ||
| 94 | |||
| 95 | loop { | ||
| 96 | // Read pkt-line length | ||
| 97 | if offset + 4 > body.len() { | ||
| 98 | break; | ||
| 99 | } | ||
| 100 | |||
| 101 | let length_str = std::str::from_utf8(&body[offset..offset+4])?; | ||
| 102 | let length = u16::from_str_radix(length_str, 16)? as usize; | ||
| 103 | |||
| 104 | // Check for flush packet | ||
| 105 | if length == 0 { | ||
| 106 | break; | ||
| 107 | } | ||
| 108 | |||
| 109 | // Extract payload | ||
| 110 | let payload_end = offset + length; | ||
| 111 | if payload_end > body.len() { | ||
| 112 | return Err(Error::InvalidPktLine); | ||
| 113 | } | ||
| 114 | |||
| 115 | let payload = &body[offset+4..payload_end]; | ||
| 116 | |||
| 117 | // Parse ref update from payload | ||
| 118 | // Format: "<old-oid> <new-oid> <ref-name>[\0<capabilities>]\n" | ||
| 119 | let payload_str = std::str::from_utf8(payload)?; | ||
| 120 | |||
| 121 | // Remove trailing newline | ||
| 122 | let line = payload_str.trim_end_matches('\n'); | ||
| 123 | |||
| 124 | // Split on null byte (first line has capabilities) | ||
| 125 | let parts: Vec<&str> = line.split('\0').collect(); | ||
| 126 | let ref_line = parts[0]; | ||
| 127 | |||
| 128 | // Parse old-oid, new-oid, ref-name | ||
| 129 | let tokens: Vec<&str> = ref_line.split_whitespace().collect(); | ||
| 130 | if tokens.len() != 3 { | ||
| 131 | return Err(Error::InvalidRefUpdate); | ||
| 132 | } | ||
| 133 | |||
| 134 | updates.push(RefUpdate { | ||
| 135 | old_oid: tokens[0].to_string(), | ||
| 136 | new_oid: tokens[1].to_string(), | ||
| 137 | ref_name: tokens[2].to_string(), | ||
| 138 | }); | ||
| 139 | |||
| 140 | offset = payload_end; | ||
| 141 | } | ||
| 142 | |||
| 143 | Ok(updates) | ||
| 144 | } | ||
| 145 | ``` | ||
| 146 | |||
| 147 | ## Special OID Values | ||
| 148 | |||
| 149 | - `0000000000000000000000000000000000000000` (40 zeros) = ref creation | ||
| 150 | - When `old_oid` is all zeros: creating a new ref | ||
| 151 | - When `new_oid` is all zeros: deleting a ref | ||
| 152 | |||
| 153 | ## Validation Requirements | ||
| 154 | |||
| 155 | For GRASP-01, we must validate: | ||
| 156 | |||
| 157 | ### 1. Regular Branches/Tags | ||
| 158 | |||
| 159 | ```rust | ||
| 160 | fn validate_regular_ref( | ||
| 161 | state: &RepositoryState, | ||
| 162 | update: &RefUpdate, | ||
| 163 | ) -> Result<()> { | ||
| 164 | // Extract branch/tag name | ||
| 165 | let (ref_type, name) = if update.ref_name.starts_with("refs/heads/") { | ||
| 166 | ("branch", &update.ref_name[11..]) | ||
| 167 | } else if update.ref_name.starts_with("refs/tags/") { | ||
| 168 | ("tag", &update.ref_name[10..]) | ||
| 169 | } else { | ||
| 170 | return Err(Error::InvalidRefName); | ||
| 171 | }; | ||
| 172 | |||
| 173 | // Check against state | ||
| 174 | let expected = if ref_type == "branch" { | ||
| 175 | state.branches.get(name) | ||
| 176 | } else { | ||
| 177 | state.tags.get(name) | ||
| 178 | }; | ||
| 179 | |||
| 180 | match expected { | ||
| 181 | Some(oid) if oid == &update.new_oid => Ok(()), | ||
| 182 | Some(oid) => Err(Error::StateMismatch { | ||
| 183 | ref_name: update.ref_name.clone(), | ||
| 184 | expected: oid.clone(), | ||
| 185 | got: update.new_oid.clone(), | ||
| 186 | }), | ||
| 187 | None => Err(Error::RefNotInState(update.ref_name.clone())), | ||
| 188 | } | ||
| 189 | } | ||
| 190 | ``` | ||
| 191 | |||
| 192 | ### 2. PR Refs (refs/nostr/<event-id>) | ||
| 193 | |||
| 194 | ```rust | ||
| 195 | fn validate_pr_ref(update: &RefUpdate) -> Result<()> { | ||
| 196 | // Extract event ID | ||
| 197 | let event_id = &update.ref_name[11..]; // Skip "refs/nostr/" | ||
| 198 | |||
| 199 | // Validate it's a valid 32-byte hex | ||
| 200 | if event_id.len() != 64 { | ||
| 201 | return Err(Error::InvalidEventId); | ||
| 202 | } | ||
| 203 | |||
| 204 | if !event_id.chars().all(|c| c.is_ascii_hexdigit()) { | ||
| 205 | return Err(Error::InvalidEventId); | ||
| 206 | } | ||
| 207 | |||
| 208 | // TODO: Could optionally verify event exists on relay | ||
| 209 | // TODO: Could verify event references this repository | ||
| 210 | |||
| 211 | Ok(()) | ||
| 212 | } | ||
| 213 | ``` | ||
| 214 | |||
| 215 | ### 3. Reject pr/* Branches | ||
| 216 | |||
| 217 | ```rust | ||
| 218 | fn reject_pr_branches(update: &RefUpdate) -> Result<()> { | ||
| 219 | if update.ref_name.starts_with("refs/heads/pr/") { | ||
| 220 | return Err(Error::InvalidRef( | ||
| 221 | "pr/* branches must use refs/nostr/<event-id>".into() | ||
| 222 | )); | ||
| 223 | } | ||
| 224 | Ok(()) | ||
| 225 | } | ||
| 226 | ``` | ||
| 227 | |||
| 228 | ## Complete Validation Flow | ||
| 229 | |||
| 230 | ```rust | ||
| 231 | pub async fn validate_push( | ||
| 232 | &self, | ||
| 233 | npub: &str, | ||
| 234 | identifier: &str, | ||
| 235 | ref_updates: Vec<RefUpdate>, | ||
| 236 | ) -> Result<()> { | ||
| 237 | // 1. Fetch events from local relay | ||
| 238 | let events = self.fetch_events(identifier).await?; | ||
| 239 | |||
| 240 | // 2. Get pubkey from npub | ||
| 241 | let pubkey = decode_npub(npub)?; | ||
| 242 | |||
| 243 | // 3. Get maintainer set (recursive) | ||
| 244 | let maintainers = get_maintainers(&events, &pubkey, identifier); | ||
| 245 | if maintainers.is_empty() { | ||
| 246 | return Err(Error::NoAnnouncement); | ||
| 247 | } | ||
| 248 | |||
| 249 | // 4. Get latest state from maintainers | ||
| 250 | let state = get_state_from_maintainers(&events, &maintainers)?; | ||
| 251 | |||
| 252 | // 5. Validate each ref update | ||
| 253 | for update in ref_updates { | ||
| 254 | // Check for pr/* branches (reject) | ||
| 255 | reject_pr_branches(&update)?; | ||
| 256 | |||
| 257 | // Handle refs/nostr/* (allow) | ||
| 258 | if update.ref_name.starts_with("refs/nostr/") { | ||
| 259 | validate_pr_ref(&update)?; | ||
| 260 | continue; | ||
| 261 | } | ||
| 262 | |||
| 263 | // Validate against state | ||
| 264 | validate_regular_ref(&state, &update)?; | ||
| 265 | } | ||
| 266 | |||
| 267 | Ok(()) | ||
| 268 | } | ||
| 269 | ``` | ||
| 270 | |||
| 271 | ## Integration with actix-web | ||
| 272 | |||
| 273 | ```rust | ||
| 274 | pub async fn git_receive_pack( | ||
| 275 | req: HttpRequest, | ||
| 276 | mut payload: web::Payload, | ||
| 277 | state: web::Data<AppState>, | ||
| 278 | ) -> Result<HttpResponse> { | ||
| 279 | // 1. Extract repo info from path | ||
| 280 | let path = req.path(); | ||
| 281 | let (npub, identifier) = parse_repo_path(path)?; | ||
| 282 | |||
| 283 | // 2. Check repository exists | ||
| 284 | if !state.repo_manager.exists(&npub, &identifier).await { | ||
| 285 | return Ok(HttpResponse::NotFound().body("Repository not found")); | ||
| 286 | } | ||
| 287 | |||
| 288 | // 3. Read request body (need to buffer for parsing) | ||
| 289 | let mut body = web::BytesMut::new(); | ||
| 290 | while let Some(chunk) = payload.next().await { | ||
| 291 | body.extend_from_slice(&chunk?); | ||
| 292 | } | ||
| 293 | |||
| 294 | // 4. Parse ref updates from body | ||
| 295 | let ref_updates = parse_ref_updates(&body)?; | ||
| 296 | |||
| 297 | // 5. VALIDATE! | ||
| 298 | let validator = PushValidator::new(state.nostr_client.clone()); | ||
| 299 | if let Err(e) = validator.validate_push(&npub, &identifier, ref_updates).await { | ||
| 300 | return Ok(HttpResponse::Forbidden() | ||
| 301 | .content_type("text/plain") | ||
| 302 | .body(format!("error: {}\n", e))); | ||
| 303 | } | ||
| 304 | |||
| 305 | // 6. Valid! Spawn git-receive-pack | ||
| 306 | let repo_path = state.repo_manager.get_path(&npub, &identifier); | ||
| 307 | let mut cmd = Command::new("git"); | ||
| 308 | cmd.arg("receive-pack") | ||
| 309 | .arg("--stateless-rpc") | ||
| 310 | .arg(&repo_path) | ||
| 311 | .stdin(Stdio::piped()) | ||
| 312 | .stdout(Stdio::piped()) | ||
| 313 | .stderr(Stdio::piped()); | ||
| 314 | |||
| 315 | let mut child = cmd.spawn()?; | ||
| 316 | |||
| 317 | // 7. Write body to git stdin | ||
| 318 | let mut stdin = child.stdin.take().unwrap(); | ||
| 319 | stdin.write_all(&body).await?; | ||
| 320 | drop(stdin); | ||
| 321 | |||
| 322 | // 8. Stream git stdout back to client | ||
| 323 | let stdout = child.stdout.take().unwrap(); | ||
| 324 | let stream = FramedRead::new(stdout, BytesCodec::new()); | ||
| 325 | |||
| 326 | Ok(HttpResponse::Ok() | ||
| 327 | .content_type("application/x-git-receive-pack-result") | ||
| 328 | .streaming(stream)) | ||
| 329 | } | ||
| 330 | ``` | ||
| 331 | |||
| 332 | ## Error Responses | ||
| 333 | |||
| 334 | Git clients expect specific error formats: | ||
| 335 | |||
| 336 | ### Success | ||
| 337 | ``` | ||
| 338 | HTTP/1.1 200 OK | ||
| 339 | Content-Type: application/x-git-receive-pack-result | ||
| 340 | |||
| 341 | [git output stream] | ||
| 342 | ``` | ||
| 343 | |||
| 344 | ### Validation Failure | ||
| 345 | ``` | ||
| 346 | HTTP/1.1 403 Forbidden | ||
| 347 | Content-Type: text/plain | ||
| 348 | |||
| 349 | error: cannot push refs/heads/main to a1b2c3d as nostr state event is at f6e5d4c | ||
| 350 | ``` | ||
| 351 | |||
| 352 | The `error:` prefix makes it display nicely in git clients. | ||
| 353 | |||
| 354 | ## Testing | ||
| 355 | |||
| 356 | ```rust | ||
| 357 | #[test] | ||
| 358 | fn test_parse_ref_updates() { | ||
| 359 | let body = b"00820000000000000000000000000000000000000000 \ | ||
| 360 | a1b2c3d4e5f6789012345678901234567890abcd \ | ||
| 361 | refs/heads/main\0 report-status\n\ | ||
| 362 | 0000"; | ||
| 363 | |||
| 364 | let updates = parse_ref_updates(body).unwrap(); | ||
| 365 | assert_eq!(updates.len(), 1); | ||
| 366 | assert_eq!(updates[0].old_oid, "0000000000000000000000000000000000000000"); | ||
| 367 | assert_eq!(updates[0].new_oid, "a1b2c3d4e5f6789012345678901234567890abcd"); | ||
| 368 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 369 | } | ||
| 370 | |||
| 371 | #[tokio::test] | ||
| 372 | async fn test_validate_matching_state() { | ||
| 373 | let state = RepositoryState { | ||
| 374 | branches: HashMap::from([ | ||
| 375 | ("main".into(), "a1b2c3d4...".into()), | ||
| 376 | ]), | ||
| 377 | tags: HashMap::new(), | ||
| 378 | }; | ||
| 379 | |||
| 380 | let update = RefUpdate { | ||
| 381 | old_oid: "0000...".into(), | ||
| 382 | new_oid: "a1b2c3d4...".into(), | ||
| 383 | ref_name: "refs/heads/main".into(), | ||
| 384 | }; | ||
| 385 | |||
| 386 | assert!(validate_regular_ref(&state, &update).is_ok()); | ||
| 387 | } | ||
| 388 | ``` | ||
| 389 | |||
| 390 | ## Performance Considerations | ||
| 391 | |||
| 392 | 1. **Buffering**: We must buffer the entire request body to parse ref updates. For large pushes, this could be memory-intensive. | ||
| 393 | |||
| 394 | **Mitigation**: Limit max request size (e.g., 100MB) | ||
| 395 | |||
| 396 | 2. **Pack Data**: After ref updates, the body contains pack data. We don't need to parse this, just forward it to Git. | ||
| 397 | |||
| 398 | **Optimization**: Could use a streaming parser that only extracts ref updates, then streams the rest | ||
| 399 | |||
| 400 | 3. **Validation Speed**: State lookup and validation should be fast. | ||
| 401 | |||
| 402 | **Optimization**: Cache state events with TTL | ||
| 403 | |||
| 404 | ## Future Enhancements | ||
| 405 | |||
| 406 | ### Streaming Parser | ||
| 407 | |||
| 408 | Instead of buffering entire body: | ||
| 409 | |||
| 410 | ```rust | ||
| 411 | // Read pkt-lines until flush packet | ||
| 412 | let ref_updates = parse_ref_updates_streaming(&mut payload).await?; | ||
| 413 | |||
| 414 | // Now payload is positioned at pack data | ||
| 415 | // Stream directly to git without buffering | ||
| 416 | spawn_git_and_stream(payload, repo_path).await?; | ||
| 417 | ``` | ||
| 418 | |||
| 419 | ### Pack Inspection | ||
| 420 | |||
| 421 | For advanced validation (future): | ||
| 422 | |||
| 423 | ```rust | ||
| 424 | // Parse pack header to get object count | ||
| 425 | let (ref_updates, pack_header) = parse_receive_pack_header(&body)?; | ||
| 426 | |||
| 427 | // Could validate pack contents before accepting | ||
| 428 | validate_pack_contents(&pack_header)?; | ||
| 429 | ``` | ||
| 430 | |||
| 431 | ## References | ||
| 432 | |||
| 433 | - [Git HTTP Protocol Docs](https://git-scm.com/docs/http-protocol) | ||
| 434 | - [Git Pack Protocol](https://git-scm.com/docs/pack-protocol) | ||
| 435 | - [Pkt-line Format](https://git-scm.com/docs/protocol-common#_pkt_line_format) | ||
diff --git a/docs/reference/test-strategy.md b/docs/reference/test-strategy.md new file mode 100644 index 0000000..cc1d5b0 --- /dev/null +++ b/docs/reference/test-strategy.md | |||
| @@ -0,0 +1,1238 @@ | |||
| 1 | # Test Strategy for ngit-grasp | ||
| 2 | |||
| 3 | ## Overview | ||
| 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. | ||
| 6 | |||
| 7 | ## Testing Philosophy | ||
| 8 | |||
| 9 | 1. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly | ||
| 10 | 2. **Compliance-First**: Every requirement in the spec has a corresponding test | ||
| 11 | 3. **Reusable**: Compliance tests can validate any GRASP implementation | ||
| 12 | 4. **Clear Failures**: Test failures cite exact spec lines/sections | ||
| 13 | 5. **Comprehensive**: Unit, integration, and compliance testing | ||
| 14 | |||
| 15 | ## Test Pyramid | ||
| 16 | |||
| 17 | ``` | ||
| 18 | ╱╲ | ||
| 19 | ╱ ╲ | ||
| 20 | ╱ E2E╲ ~ 10% End-to-end with real Git | ||
| 21 | ╱──────╲ | ||
| 22 | ╱ ╲ | ||
| 23 | ╱Compliance╲ ~ 20% GRASP spec validation | ||
| 24 | ╱────────────╲ | ||
| 25 | ╱ ╲ | ||
| 26 | ╱ Integration ╲ ~ 30% Component interaction | ||
| 27 | ╱──────────────────╲ | ||
| 28 | ╱ ╲ | ||
| 29 | ╱ Unit Tests ╲ ~ 40% Individual functions | ||
| 30 | ╱────────────────────────╲ | ||
| 31 | ``` | ||
| 32 | |||
| 33 | ## GRASP Compliance Testing Tool | ||
| 34 | |||
| 35 | ### Design Goals | ||
| 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 | |||
| 45 | ``` | ||
| 46 | grasp-compliance-tests/ | ||
| 47 | ├── Cargo.toml # Standalone crate | ||
| 48 | ├── README.md # Usage instructions | ||
| 49 | ├── src/ | ||
| 50 | │ ├── lib.rs # Public API | ||
| 51 | │ ├── client.rs # Test client utilities | ||
| 52 | │ ├── assertions.rs # Spec-based assertions | ||
| 53 | │ └── specs/ | ||
| 54 | │ ├── mod.rs # Spec registry | ||
| 55 | │ ├── grasp_01.rs # GRASP-01 tests | ||
| 56 | │ ├── grasp_02.rs # GRASP-02 tests | ||
| 57 | │ └── grasp_05.rs # GRASP-05 tests | ||
| 58 | ├── fixtures/ | ||
| 59 | │ ├── repos/ # Test repositories | ||
| 60 | │ ├── events/ # Nostr event fixtures | ||
| 61 | │ └── keys/ # Test keypairs | ||
| 62 | └── examples/ | ||
| 63 | └── test_implementation.rs # Example usage | ||
| 64 | ``` | ||
| 65 | |||
| 66 | ### Spec-Mirrored Test Structure | ||
| 67 | |||
| 68 | Each GRASP spec document maps to a test module with identical structure: | ||
| 69 | |||
| 70 | ```rust | ||
| 71 | // src/specs/grasp_01.rs | ||
| 72 | |||
| 73 | use crate::{TestContext, SpecRequirement, ComplianceResult}; | ||
| 74 | |||
| 75 | /// GRASP-01 - Core Service Requirements | ||
| 76 | /// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md | ||
| 77 | pub struct Grasp01Spec; | ||
| 78 | |||
| 79 | impl Grasp01Spec { | ||
| 80 | /// Run all GRASP-01 compliance tests | ||
| 81 | pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult { | ||
| 82 | let mut results = ComplianceResult::new("GRASP-01"); | ||
| 83 | |||
| 84 | // Section: Nostr Relay | ||
| 85 | results.add(Self::test_nostr_relay_nip01_compliance(ctx).await); | ||
| 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 | |||
| 499 | // ================================================================ | ||
| 500 | // CORS SUPPORT TESTS | ||
| 501 | // ================================================================ | ||
| 502 | |||
| 503 | /// MUST set Access-Control-Allow-Origin: * on ALL responses | ||
| 504 | /// | ||
| 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 | |||
| 534 | /// MUST respond to OPTIONS requests with 204 No Content | ||
| 535 | /// | ||
| 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 | } | ||
| 557 | ``` | ||
| 558 | |||
| 559 | ### Test Result Reporting | ||
| 560 | |||
| 561 | ```rust | ||
| 562 | /// Test result with spec citation | ||
| 563 | pub struct TestResult { | ||
| 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 | |||
| 572 | impl TestResult { | ||
| 573 | /// Create a new test result | ||
| 574 | pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { | ||
| 575 | TestResult { | ||
| 576 | name: name.to_string(), | ||
| 577 | spec_ref: spec_ref.to_string(), | ||
| 578 | requirement: requirement.to_string(), | ||
| 579 | passed: false, | ||
| 580 | error: None, | ||
| 581 | duration: Duration::default(), | ||
| 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 | |||
| 608 | /// Collection of test results for a spec | ||
| 609 | pub struct ComplianceResult { | ||
| 610 | pub spec: String, | ||
| 611 | pub results: Vec<TestResult>, | ||
| 612 | } | ||
| 613 | |||
| 614 | impl ComplianceResult { | ||
| 615 | pub fn report(&self) -> String { | ||
| 616 | let mut output = String::new(); | ||
| 617 | |||
| 618 | output.push_str(&format!("\n{} Compliance Report\n", self.spec)); | ||
| 619 | output.push_str(&"=".repeat(60)); | ||
| 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 | } | ||
| 643 | |||
| 644 | output | ||
| 645 | } | ||
| 646 | } | ||
| 647 | ``` | ||
| 648 | |||
| 649 | ### Usage Example | ||
| 650 | |||
| 651 | ```rust | ||
| 652 | // examples/test_implementation.rs | ||
| 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 | ``` | ||
| 677 | |||
| 678 | ### Integration with ngit-grasp | ||
| 679 | |||
| 680 | In `ngit-grasp/tests/compliance.rs`: | ||
| 681 | |||
| 682 | ```rust | ||
| 683 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | ||
| 684 | |||
| 685 | #[tokio::test] | ||
| 686 | async fn test_grasp_01_compliance() { | ||
| 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 | ``` | ||
| 708 | |||
| 709 | ## Unit Testing Strategy | ||
| 710 | |||
| 711 | ### Git Module Tests | ||
| 712 | |||
| 713 | ```rust | ||
| 714 | // src/git/parser.rs tests | ||
| 715 | |||
| 716 | #[cfg(test)] | ||
| 717 | mod tests { | ||
| 718 | use super::*; | ||
| 719 | |||
| 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 | |||
| 749 | ### Authorization Module Tests | ||
| 750 | |||
| 751 | ```rust | ||
| 752 | // src/git/authorization.rs tests | ||
| 753 | |||
| 754 | #[cfg(test)] | ||
| 755 | mod tests { | ||
| 756 | use super::*; | ||
| 757 | |||
| 758 | #[test] | ||
| 759 | fn test_get_maintainers_single() { | ||
| 760 | let events = vec![ | ||
| 761 | create_test_announcement("alice", "repo1", vec![]), | ||
| 762 | ]; | ||
| 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 | |||
| 829 | ## Integration Testing Strategy | ||
| 830 | |||
| 831 | ### Repository Lifecycle Tests | ||
| 832 | |||
| 833 | ```rust | ||
| 834 | // tests/integration/repository_lifecycle.rs | ||
| 835 | |||
| 836 | #[tokio::test] | ||
| 837 | async fn test_repository_creation_on_announcement() { | ||
| 838 | let app = test_app().await; | ||
| 839 | |||
| 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 | } | ||
| 862 | |||
| 863 | #[tokio::test] | ||
| 864 | async fn test_push_validation_flow() { | ||
| 865 | let app = test_app().await; | ||
| 866 | |||
| 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 | } | ||
| 882 | ``` | ||
| 883 | |||
| 884 | ### Multi-Maintainer Tests | ||
| 885 | |||
| 886 | ```rust | ||
| 887 | #[tokio::test] | ||
| 888 | async fn test_multi_maintainer_push() { | ||
| 889 | let app = test_app().await; | ||
| 890 | |||
| 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 | ``` | ||
| 912 | |||
| 913 | ## End-to-End Testing | ||
| 914 | |||
| 915 | ### Real Git Client Tests | ||
| 916 | |||
| 917 | ```rust | ||
| 918 | // tests/e2e/git_client.rs | ||
| 919 | |||
| 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 | |||
| 950 | #[tokio::test] | ||
| 951 | async fn test_real_git_push() { | ||
| 952 | let app = test_app().await; | ||
| 953 | |||
| 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 | ``` | ||
| 983 | |||
| 984 | ## Performance Testing | ||
| 985 | |||
| 986 | ### Load Tests | ||
| 987 | |||
| 988 | ```rust | ||
| 989 | // tests/performance/load.rs | ||
| 990 | |||
| 991 | #[tokio::test] | ||
| 992 | async fn test_concurrent_pushes() { | ||
| 993 | let app = test_app().await; | ||
| 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 | |||
| 1019 | #[tokio::test] | ||
| 1020 | async fn test_event_ingestion_throughput() { | ||
| 1021 | let app = test_app().await; | ||
| 1022 | |||
| 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 | |||
| 1033 | let duration = start.elapsed(); | ||
| 1034 | let throughput = num_events as f64 / duration.as_secs_f64(); | ||
| 1035 | |||
| 1036 | println!("Event throughput: {:.2} events/sec", throughput); | ||
| 1037 | assert!(throughput > 100.0, "Throughput too low"); | ||
| 1038 | } | ||
| 1039 | ``` | ||
| 1040 | |||
| 1041 | ## Test Utilities | ||
| 1042 | |||
| 1043 | ### Test Fixtures | ||
| 1044 | |||
| 1045 | ```rust | ||
| 1046 | // tests/common/fixtures.rs | ||
| 1047 | |||
| 1048 | pub struct TestEventBuilder { | ||
| 1049 | kind: Kind, | ||
| 1050 | content: String, | ||
| 1051 | tags: Vec<Tag>, | ||
| 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 | } | ||
| 1063 | } | ||
| 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 | } | ||
| 1126 | ``` | ||
| 1127 | |||
| 1128 | ## CI/CD Integration | ||
| 1129 | |||
| 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 | ``` | ||
| 1179 | |||
| 1180 | ## Test Coverage | ||
| 1181 | |||
| 1182 | ### Target Coverage | ||
| 1183 | |||
| 1184 | - **Unit Tests**: >80% line coverage | ||
| 1185 | - **Integration Tests**: All critical paths | ||
| 1186 | - **Compliance Tests**: 100% of GRASP-01 requirements | ||
| 1187 | - **E2E Tests**: Key user workflows | ||
| 1188 | |||
| 1189 | ### Measuring Coverage | ||
| 1190 | |||
| 1191 | ```bash | ||
| 1192 | # Install tarpaulin | ||
| 1193 | cargo install cargo-tarpaulin | ||
| 1194 | |||
| 1195 | # Run with coverage | ||
| 1196 | cargo tarpaulin --out Html --output-dir coverage | ||
| 1197 | |||
| 1198 | # View report | ||
| 1199 | open coverage/index.html | ||
| 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 | ``` | ||
| 1223 | |||
| 1224 | ## Summary | ||
| 1225 | |||
| 1226 | This comprehensive test strategy ensures: | ||
| 1227 | |||
| 1228 | 1. **Spec Compliance**: Every GRASP requirement has a corresponding test | ||
| 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 | |||
| 1234 | The compliance testing tool is a standalone crate that can be: | ||
| 1235 | - Used by ngit-grasp for self-validation | ||
| 1236 | - Published for other GRASP implementations to use | ||
| 1237 | - Updated as new GRASP specs are released (GRASP-02, GRASP-05) | ||
| 1238 | - Run in CI/CD for continuous compliance verification | ||