upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 15:41:32 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 15:41:32 +0000
commitc54ce061d6d278cce8362d5af085808ca60c239b (patch)
treeec967d6195d9f7ec4f061449596611afe3a0950f
parente0ad39a489b3398f8208713bf728db0cb11475b0 (diff)
parent113928aa84894ea8f65c247d9987527e792b32a9 (diff)
feat: announcement purgatory
Extends purgatory to hold repository announcements until git data arrives, preventing empty repositories from being served to clients. When an announcement is received, a bare repo is created immediately and the announcement is held in purgatory. It is only promoted and served once a git push confirms real content exists. If no push arrives before expiry, the bare repo is deleted and the announcement is silently discarded. Key behaviours: - Soft expiry: announcements are hidden from clients but kept alive while git pushes are in progress, reviving on successful push - Expiry is extended when a matching state event or git push is observed - NIP-09 deletion events remove announcements from purgatory - Purgatory state (announcements, state events, PR events, expired set) is persisted to disk on graceful shutdown and restored on startup, with elapsed downtime subtracted from expiry deadlines - Purgatory announcements drive StateOnly sync in the sync system so state events are fetched from listed relays before promotion - SyncLevel added to RepoSyncIndex to distinguish purgatory repos (StateOnly) from promoted repos (Full L2+L3 sync)
-rw-r--r--docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md67
-rw-r--r--docs/explanation/grasp-02-proactive-sync.md57
-rw-r--r--docs/explanation/purgatory-design.md574
-rw-r--r--grasp-audit/README.md10
-rw-r--r--grasp-audit/src/client.rs30
-rw-r--r--grasp-audit/src/fixtures.rs522
-rw-r--r--grasp-audit/src/result.rs43
-rw-r--r--grasp-audit/src/specs/grasp01/cors.rs45
-rw-r--r--grasp-audit/src/specs/grasp01/event_acceptance_policy.rs165
-rw-r--r--grasp-audit/src/specs/grasp01/git_clone.rs49
-rw-r--r--grasp-audit/src/specs/grasp01/git_filter.rs43
-rw-r--r--grasp-audit/src/specs/grasp01/mod.rs6
-rw-r--r--grasp-audit/src/specs/grasp01/nip01_smoke.rs39
-rw-r--r--grasp-audit/src/specs/grasp01/nip11_document.rs17
-rw-r--r--grasp-audit/src/specs/grasp01/purgatory.rs983
-rw-r--r--grasp-audit/src/specs/grasp01/push_authorization.rs232
-rw-r--r--grasp-audit/src/specs/grasp01/repository_creation.rs35
-rw-r--r--grasp-audit/src/specs/grasp01/spec_requirements.rs150
-rw-r--r--grasp-audit/src/specs/mod.rs2
-rw-r--r--src/git/authorization.rs59
-rw-r--r--src/git/handlers.rs7
-rw-r--r--src/git/sync.rs234
-rw-r--r--src/http/mod.rs22
-rw-r--r--src/main.rs21
-rw-r--r--src/nostr/builder.rs33
-rw-r--r--src/nostr/policy/announcement.rs273
-rw-r--r--src/nostr/policy/deletion.rs498
-rw-r--r--src/nostr/policy/mod.rs2
-rw-r--r--src/nostr/policy/pr_event.rs8
-rw-r--r--src/nostr/policy/related.rs5
-rw-r--r--src/nostr/policy/state.rs75
-rw-r--r--src/purgatory/mod.rs659
-rw-r--r--src/purgatory/sync/context.rs24
-rw-r--r--src/purgatory/types.rs39
-rw-r--r--src/sync/algorithms.rs58
-rw-r--r--src/sync/filters.rs31
-rw-r--r--src/sync/mod.rs167
-rw-r--r--src/sync/self_subscriber.rs34
-rw-r--r--tests/archive_grasp_services.rs225
-rw-r--r--tests/archive_read_only.rs368
-rw-r--r--tests/common/purgatory_helpers.rs38
-rw-r--r--tests/common/relay.rs13
-rw-r--r--tests/common/sync_helpers.rs423
-rw-r--r--tests/nip77_negentropy.rs69
-rw-r--r--tests/purgatory.rs89
-rw-r--r--tests/purgatory_persistence.rs157
-rw-r--r--tests/purgatory_sync.rs365
-rw-r--r--tests/sync/discovery.rs259
-rw-r--r--tests/sync/historic_sync.rs32
-rw-r--r--tests/sync/live_sync.rs119
-rw-r--r--tests/sync/maintainer_reprocessing.rs278
-rw-r--r--tests/sync/metrics.rs139
-rw-r--r--tests/sync/mod.rs10
-rw-r--r--tests/sync/tag_variations.rs244
54 files changed, 6091 insertions, 2055 deletions
diff --git a/docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md b/docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md
index 31c3e46..8fb5798 100644
--- a/docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md
+++ b/docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md
@@ -12,7 +12,13 @@
12 12
13## Overview 13## Overview
14 14
15When Nostr events arrive before their git data, they enter **purgatory** waiting to be served. But they don't wait passively—ngit-grasp **actively hunts** for the missing git data across all git servers assoicated with the repo until it finds what it needs. 15When Nostr events arrive before their git data, they enter **purgatory** waiting to be served. But they don't wait passively—ngit-grasp **actively hunts** for the missing git data across all git servers associated with the repo until it finds what it needs.
16
17This applies to three types of purgatory entries:
18
19- **Announcement purgatory** — kind 30617 announcements waiting for a git push to prove the repo has content
20- **State event purgatory** — kind 30618 state events waiting for their referenced git objects
21- **PR event purgatory** — kind 1617/1618 PR events waiting for their referenced commits
16 22
17### How It Works 23### How It Works
18 24
@@ -42,6 +48,7 @@ We respect remote server capacity with:
42✅ **Respectful throttling** - 5 concurrent + 30/min per domain, plays nice with other implementations 48✅ **Respectful throttling** - 5 concurrent + 30/min per domain, plays nice with other implementations
43✅ **Smart timing** - 3min delay for user pushes, 500ms for synced events 49✅ **Smart timing** - 3min delay for user pushes, 500ms for synced events
44✅ **30min expiry** - Auto-cleanup of events when data never arrives 50✅ **30min expiry** - Auto-cleanup of events when data never arrives
51✅ **Soft expiry for announcements** - Bare repo deleted at 30min, event retained 24h to allow revival
45✅ **Fully testable** - Mock-based architecture for reliable unit tests 52✅ **Fully testable** - Mock-based architecture for reliable unit tests
46 53
47--- 54---
@@ -73,6 +80,16 @@ Timeline D: Data never arrives
73 t=60s: Retry → all servers checked, no data 80 t=60s: Retry → all servers checked, no data
74 ... 81 ...
75 t=1800s: 30 minutes expired → event discarded, purgatory cleaned up 🗑️ 82 t=1800s: 30 minutes expired → event discarded, purgatory cleaned up 🗑️
83
84Timeline E: Announcement purgatory (no git data within 30 min)
85 t=0s: Announcement received → bare repo created, enters announcement purgatory
86 t=0.5s: Start hunting git servers for any content
87 ...
88 t=1800s: 30 minutes expired → bare repo deleted, event retained (soft_expired=true)
89 t=3600s: State event arrives (slow sync) → bare repo recreated, expiry reset ✅
90 t=5400s: Git push arrives → announcement promoted to DB, served to clients ✅
91 OR
92 t=86400s: 24 hours elapsed, no revival → event added to expired_events, removed 🗑️
76``` 93```
77 94
78**Without proactive sync**: Events in Timeline C would wait indefinitely (or until manual git push). 95**Without proactive sync**: Events in Timeline C would wait indefinitely (or until manual git push).
@@ -330,11 +347,11 @@ Both methods check `has_capacity()` and trigger `try_process_next()` if true.
330 347
331--- 348---
332 349
333## 30-Minute Purgatory Expiry 350## Purgatory Expiry
334 351
335Purgatory entries **automatically expire** after 30 minutes to prevent unbounded memory growth. 352### State and PR Events: 30-Minute Hard Expiry
336 353
337### Why 30 Minutes? 354State and PR purgatory entries **automatically expire** after 30 minutes.
338 355
339From the [GRASP-01 spec](https://github.com/DanConwayDev/grasp/blob/main/01.md#purgatory): 356From the [GRASP-01 spec](https://github.com/DanConwayDev/grasp/blob/main/01.md#purgatory):
340 357
@@ -346,25 +363,40 @@ This balances:
346- 🧹 **Short enough** to prevent memory leaks from abandoned events 363- 🧹 **Short enough** to prevent memory leaks from abandoned events
347- 🔄 **Recoverable** events are still on other relays and can be re-submitted 364- 🔄 **Recoverable** events are still on other relays and can be re-submitted
348 365
349### Implementation 366Each entry tracks `expires_at: Instant` (30 min from creation). The sync loop checks expiry before processing via `has_pending_events()`. If all events for an identifier have expired, the identifier is removed from the sync queue.
350 367
351Each purgatory entry tracks: 368To prevent infinite re-sync loops, expired event IDs are added to an `expired_events` set. If a sync delivers an event that previously expired, it is rejected with `"previously expired from purgatory without git data"`.
352 369
353- `created_at: Instant` - When added to purgatory 370**Implementation**: [`src/purgatory/mod.rs:DEFAULT_EXPIRY`](../../src/purgatory/mod.rs)
354- `expires_at: Instant` - When to discard (created_at + 30min)
355 371
356The main sync loop checks expiry before processing: 372### Announcement Purgatory: Two-Phase Soft Expiry
357 373
358```rust 374Announcements use a different expiry strategy because they have an additional concern: the bare git repo created on arrival must be cleaned up, but we also need to avoid re-syncing the announcement event on every sync cycle.
359if !self.has_pending_events(&identifier) {
360 // No events remain (expired or released) → remove from sync queue
361 self.sync_queue.remove(&identifier);
362}
363```
364 375
365**Note**: Expiry is checked implicitly via `has_pending_events()`. If all events for an identifier have expired, the identifier is removed from the sync queue. 376**Phase 1 — Initial 30-minute expiry:**
366 377
367**Implementation**: [`src/purgatory/mod.rs:DEFAULT_EXPIRY`](../../src/purgatory/mod.rs) 378- Delete the bare git repo (frees disk space, respects the protocol's 30-minute expiry)
379- Set `soft_expired = true` on the entry
380- Extend `expires_at` by **24 hours** (`SOFT_EXPIRY_EXTENDED`)
381- Continue syncing state events for this repo (same as active purgatory)
382
383**Phase 2 — 24-hour soft expiry:**
384
385- Add event ID to `expired_events` (prevents re-sync loops)
386- Remove entry completely from `announcement_purgatory`
387
388**Why not just hard-expire at 30 minutes?**
389
390The protocol's 30-minute expiry creates a dilemma for announcements:
391
392- **Option A: Add to `failed_events` at 30 min** → Permanently rejects future state events, losing potential revival when state events arrive late (e.g. from a slow sync)
393- **Option B: Remove entirely at 30 min** → The announcement gets re-fetched on every subsequent sync cycle, wasting bandwidth indefinitely
394
395Soft expiry is the solution: the bare repo is deleted at 30 minutes (respecting the protocol), but the event is retained for 24 hours. During this window, a late-arriving state event can **revive** the announcement—`extend_announcement_expiry()` recreates the bare repo, clears `soft_expired`, and resets the 30-minute timer. After 24 hours with no revival, the event is added to `expired_events` and fully removed.
396
397**Why 24 hours specifically?** This covers the worst-case sync delay. A relay that was offline for up to 24 hours will re-sync state events when it reconnects. The 24-hour window ensures announcements remain revivable throughout that period without permanently occupying disk space.
398
399**Implementation**: [`src/purgatory/mod.rs:SOFT_EXPIRY_EXTENDED`](../../src/purgatory/mod.rs)
368 400
369--- 401---
370 402
@@ -670,6 +702,7 @@ The purgatory sync system is a sophisticated, production-ready implementation th
670✅ **Throttles respectfully** - 5 concurrent + 30/min per domain, round-robin fairness 702✅ **Throttles respectfully** - 5 concurrent + 30/min per domain, round-robin fairness
671✅ **Times strategically** - 3min for user events, 500ms for synced events 703✅ **Times strategically** - 3min for user events, 500ms for synced events
672✅ **Expires responsibly** - 30min auto-cleanup prevents memory leaks 704✅ **Expires responsibly** - 30min auto-cleanup prevents memory leaks
705✅ **Soft-expires announcements** - Bare repo deleted at 30min, event retained 24h for revival
673✅ **Tests thoroughly** - Mock-based architecture enables comprehensive unit tests 706✅ **Tests thoroughly** - Mock-based architecture enables comprehensive unit tests
674 707
675This design ensures ngit-grasp can serve repositories reliably even when git data and Nostr events arrive out-of-order or from different sources, while respecting remote server capacity and providing excellent observability. 708This design ensures ngit-grasp can serve repositories reliably even when git data and Nostr events arrive out-of-order or from different sources, while respecting remote server capacity and providing excellent observability.
diff --git a/docs/explanation/grasp-02-proactive-sync.md b/docs/explanation/grasp-02-proactive-sync.md
index ed8fdbf..6696e27 100644
--- a/docs/explanation/grasp-02-proactive-sync.md
+++ b/docs/explanation/grasp-02-proactive-sync.md
@@ -47,20 +47,37 @@ This state starts afresh when the binary loads.
47### RepoSyncIndex (Source of Truth) 47### RepoSyncIndex (Source of Truth)
48 48
49```rust 49```rust
50/// What we WANT to sync - derived from events received via self-subscription. 50/// What we WANT to sync - derived from events received via self-subscription
51/// Updated immediately when self-subscriber batch fires. 51/// and from purgatory announcements.
52/// Updated immediately when self-subscriber batch fires or purgatory sync timer runs.
52/// Key: repo addressable ref - 30617:pubkey:identifier 53/// Key: repo addressable ref - 30617:pubkey:identifier
53pub type RepoSyncIndex = Arc<RwLock<HashMap<String, RepoSyncNeeds>>>; 54pub type RepoSyncIndex = Arc<RwLock<HashMap<String, RepoSyncNeeds>>>;
54 55
56/// Controls which sync filters are built for a repo
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum SyncLevel {
59 #[default]
60 Full, // Full L2 + L3 sync (promoted repos with git data)
61 StateOnly, // Only state events (kind 30618) — for purgatory announcements
62}
63
55#[derive(Debug, Clone, Default)] 64#[derive(Debug, Clone, Default)]
56pub struct RepoSyncNeeds { 65pub struct RepoSyncNeeds {
57 /// Relay URLs listed in this repo's 30617 announcement 66 /// Relay URLs listed in this repo's 30617 announcement
58 pub relays: HashSet<String>, 67 pub relays: HashSet<String>,
59 /// Root event IDs - 1617/1618/1621 - that reference this repo 68 /// Root event IDs - 1617/1618/1621 - that reference this repo
60 pub root_events: HashSet<EventId>, 69 pub root_events: HashSet<EventId>,
70 /// Controls which filters are built: Full (L2+L3) or StateOnly (kind 30618 only)
71 pub sync_level: SyncLevel,
61} 72}
62``` 73```
63 74
75**Two sources populate `RepoSyncIndex`:**
76
771. **`SelfSubscriber`** — monitors the relay's own event stream for accepted announcements (kinds 30617, 1617, 1618, 1621). Adds entries with `SyncLevel::Full`. When an announcement is promoted from purgatory to the database, the SelfSubscriber sees it and upgrades the entry to `Full`.
78
792. **Purgatory announcement sync timer** (`run_purgatory_announcement_sync`, every 5 seconds) — iterates `purgatory.announcements_for_sync()` and ensures each purgatory announcement has a `SyncLevel::StateOnly` entry in `RepoSyncIndex`. This is the only registration path for purgatory announcements because they are not saved to the database and therefore never seen by the SelfSubscriber.
80
64### RelaySyncIndex (Confirmed State + Connection) 81### RelaySyncIndex (Confirmed State + Connection)
65 82
66```rust 83```rust
@@ -336,7 +353,23 @@ The sync system uses three background tasks that run continuously:
336 353
3371. Queue events to `PendingUpdates` 3541. Queue events to `PendingUpdates`
3382. Timer fires (interval, does not reset on events) 3552. Timer fires (interval, does not reset on events)
3393. Process batch: update RepoSyncIndex → derive targets → send AddFilters to SyncManager 3563. Process batch: update RepoSyncIndex with `SyncLevel::Full` → derive targets → send AddFilters to SyncManager
357
358**Note**: The SelfSubscriber only sees announcements that have been accepted to the database (promoted from purgatory). Purgatory announcements are registered separately by the purgatory sync timer (see below).
359
360### 4. Purgatory Announcement Sync Timer (`run_purgatory_announcement_sync`)
361
362**Purpose**: Register purgatory announcements in `RepoSyncIndex` so state events are synced for them
363
364**Interval**: Every 5 seconds (200ms in test mode)
365
366**Flow**:
367
3681. Iterate `purgatory.announcements_for_sync()`
3692. For each announcement not already in `RepoSyncIndex`: insert with `SyncLevel::StateOnly`
3703. When an announcement is promoted (git data arrives), the SelfSubscriber sees the newly accepted event and upgrades the entry to `SyncLevel::Full`
371
372**Why a separate timer?** Purgatory announcements are never saved to the database, so the SelfSubscriber never sees them. The timer bridges this gap, ensuring state events are synced for repos that may still receive git data.
340 373
341--- 374---
342 375
@@ -602,9 +635,10 @@ flowchart TB
602 635
603- Self-subscriber monitors own relay for 30617, 1617, 1618, 1621 (NOT 1619 or 30618) 636- Self-subscriber monitors own relay for 30617, 1617, 1618, 1621 (NOT 1619 or 30618)
604- Batches events in `PendingUpdates` (5 second window via interval timer) 637- Batches events in `PendingUpdates` (5 second window via interval timer)
605- `process_batch()` updates RepoSyncIndex, then builds AddFilters **directly** (no compute_actions) 638- `process_batch()` updates RepoSyncIndex with `SyncLevel::Full`, then builds AddFilters **directly** (no compute_actions)
606- AddFilters sent via channel to SyncManager, which calls `handle_new_sync_filters()` 639- AddFilters sent via channel to SyncManager, which calls `handle_new_sync_filters()`
607- This path does NOT use compute_actions because it's building fresh filters from the updated index 640- This path does NOT use compute_actions because it's building fresh filters from the updated index
641- Purgatory announcements (not in DB) are registered separately by the purgatory sync timer with `SyncLevel::StateOnly`
608 642
609--- 643---
610 644
@@ -687,16 +721,23 @@ fn compute_actions(
687- **Tags**: lowercase `a`, uppercase `A`, and `q` tags for comprehensive coverage 721- **Tags**: lowercase `a`, uppercase `A`, and `q` tags for comprehensive coverage
688- **Batching**: Per 100 repo refs 722- **Batching**: Per 100 repo refs
689- **Function**: `build_repo_tag_filters(repos, since)` 723- **Function**: `build_repo_tag_filters(repos, since)`
724- **Only for `SyncLevel::Full` repos** — purgatory announcements (`StateOnly`) skip this layer
690 725
691### Layer 3: Events Tagging Our Root Events 726### Layer 3: Events Tagging Our Root Events
692 727
693- **Tags**: lowercase `e`, uppercase `E`, and `q` tags for comprehensive coverage 728- **Tags**: lowercase `e`, uppercase `E`, and `q` tags for comprehensive coverage
694- **Batching**: Per 100 event IDs 729- **Batching**: Per 100 event IDs
695- **Function**: `build_root_event_tag_filters(root_events, since)` 730- **Function**: `build_root_event_tag_filters(root_events, since)`
731- **Only for `SyncLevel::Full` repos** — purgatory announcements (`StateOnly`) skip this layer
732
733### Combined Layer 2+3 (SyncLevel-Aware)
734
735The `build_sync_level_aware_filters()` function combines both layers, partitioning repos by `SyncLevel`:
696 736
697### Combined Layer 2+3 737- **`Full` repos**: state event filters + repo-tag filters + root-event-tag filters
738- **`StateOnly` repos**: state event filters only (kind 30618 with `#d` tags)
698 739
699The `build_layer2_and_layer3_filters()` function combines both layers. Used by: 740Used by:
700 741
701- `recompute_new_sync_filters_for_relay` for new item subscriptions 742- `recompute_new_sync_filters_for_relay` for new item subscriptions
702- `reconstruct_filters` for rebuilding from confirmed state 743- `reconstruct_filters` for rebuilding from confirmed state
@@ -871,9 +912,9 @@ flowchart TB
871 912
872``` 913```
873src/sync/ 914src/sync/
874├── mod.rs # SyncManager, main loop, data structures 915├── mod.rs # SyncManager, main loop, data structures, SyncLevel, run_purgatory_announcement_sync
875├── algorithms.rs # derive_relay_targets(), compute_actions() 916├── algorithms.rs # derive_relay_targets(), compute_actions()
876├── filters.rs # build_announcement_filter(), build_layer2_and_layer3_filters() 917├── filters.rs # build_announcement_filter(), build_sync_level_aware_filters()
877├── health.rs # RelayHealthTracker with exponential backoff 918├── health.rs # RelayHealthTracker with exponential backoff
878├── relay_connection.rs # RelayConnection, RelayEvent handling 919├── relay_connection.rs # RelayConnection, RelayEvent handling
879├── self_subscriber.rs # SelfSubscriber with batching 920├── self_subscriber.rs # SelfSubscriber with batching
diff --git a/docs/explanation/purgatory-design.md b/docs/explanation/purgatory-design.md
index b984745..8e7d75c 100644
--- a/docs/explanation/purgatory-design.md
+++ b/docs/explanation/purgatory-design.md
@@ -8,7 +8,11 @@
8 8
9## Overview 9## Overview
10 10
11Purgatory is an in-memory holding area that solves the **"which arrives first?"** problem in GRASP. Either nostr events or git pushes can arrive in any order: 11Purgatory is an in-memory holding area that solves two related problems in GRASP:
12
13### Problem 1: "Which arrives first?" (State and PR events)
14
15Either nostr events or git pushes can arrive in any order:
12 16
13- **Event first**: Event waits in purgatory until git data arrives 17- **Event first**: Event waits in purgatory until git data arrives
14- **Git first**: Placeholder waits in purgatory until event arrives 18- **Git first**: Placeholder waits in purgatory until event arrives
@@ -19,28 +23,61 @@ When both halves arrive, they are processed together and saved to the database.
19 23
20> Accepted repo state announcements, PRs and PR Updates SHOULD be accepted with message "purgatory: won't be served until git data arrives" and kept in purgatory (not served) until the related git data arrives and otherwise discarded after 30 minutes. 24> Accepted repo state announcements, PRs and PR Updates SHOULD be accepted with message "purgatory: won't be served until git data arrives" and kept in purgatory (not served) until the related git data arrives and otherwise discarded after 30 minutes.
21 25
26### Problem 2: Misleading empty repository announcements
27
28When a repository announcement arrives, we must create the bare git repo immediately so pushes can succeed. But if no git data ever arrives, we would serve an empty repo and its announcement indefinitely—clients see the announcement, try to clone, and get nothing.
29
30**Solution**: New announcements go to **announcement purgatory** instead of being immediately accepted:
31
321. **Announcement arrives** → Create bare repo immediately, add announcement to purgatory
332. **Git data arrives** → Promote announcement from purgatory to active (now served to clients)
343. **No git data before expiry** → Delete bare repo, discard announcement (never served)
35
36This ensures we only serve announcements for repos that actually have content.
37
22--- 38---
23 39
24## Key Design Principles 40## Key Design Principles
25 41
26### 1. In-Memory Only 42### 1. Graceful-Shutdown Persistence
43
44Purgatory state is **saved to disk on graceful shutdown** and **restored on startup**. This preserves in-flight work across planned restarts (deployments, reboots).
45
46On `SIGINT` / Ctrl-C, `main.rs` calls `purgatory.save_to_disk()` before exiting. On startup, if the state file exists, `purgatory.restore_from_disk()` is called before the server begins accepting connections.
47
48**What is persisted:**
49
50| Store | Persisted? | Notes |
51|-------|-----------|-------|
52| `announcement_purgatory` | ✅ Yes | Non-soft-expired entries only (bare repo must exist) |
53| `state_events` | ✅ Yes | All active entries |
54| `pr_events` | ✅ Yes | Both events and placeholders |
55| `expired_events` | ✅ Yes | Prevents re-sync loops after restart |
56| `sync_queue` | ❌ No | Rebuilt automatically after restore |
27 57
28Purgatory data is **not persisted** to disk. On restart, all purgatory entries are lost. This is acceptable because: 58**What is NOT persisted (unclean shutdown):**
59
60On a crash or `SIGKILL`, the state file is not written. In that case:
29 61
30- Events are still on other relays (can be re-submitted) 62- Events are still on other relays (can be re-submitted)
31- Git data can be re-pushed 63- Git data can be re-pushed
32- 30-minute expiry means data is transient anyway 64- 30-minute expiry means data is transient anyway
33 65
34### 2. Separate Storage for State vs PR Events 66**State file location:** `<git_data_path>/purgatory-state.json`
67
68**Downtime accounting:** Expiry deadlines are stored as duration offsets from the save timestamp. On restore, elapsed downtime is subtracted from each deadline. Entries that expired during downtime are immediately swept by the next cleanup tick.
69
70**Soft-expired announcements are excluded:** Their bare repos have already been deleted, so they cannot be meaningfully restored. They will be re-fetched via background sync if needed.
35 71
36State events (kind 30618) and PR events (kind 1617/1618) have fundamentally different matching patterns: 72### 2. Separate Storage for Each Event Type
37 73
38| Event Type | Index | Matching Strategy | 74| Store | Index | Purpose |
39|------------|-------|-------------------| 75|-------|-------|---------|
40| **State Events** | `identifier` (d tag) | Compare refs at push time | 76| `announcement_purgatory` | `(PublicKey, String)` — `(owner, identifier)` | Announcements awaiting git data |
41| **PR Events** | `event_id` (hex string) | Direct match via `refs/nostr/<event-id>` | 77| `state_events` | `identifier` (d tag) | State events awaiting git data |
78| `pr_events` | `event_id` (hex string) | PR events awaiting git data |
42 79
43They use **separate DashMap stores** for efficient concurrent access. 80Announcement purgatory uses `(pubkey, identifier)` because identifier alone is not unique across different owners.
44 81
45### 3. Late Binding for State Events 82### 3. Late Binding for State Events
46 83
@@ -78,7 +115,23 @@ With purgatory checking during authorization:
782. Git push arrives → Checks **database + purgatory** → State found → **AUTHORIZED** ✅ 1152. Git push arrives → Checks **database + purgatory** → State found → **AUTHORIZED** ✅
793. After push succeeds → Save event to database → Remove from purgatory 1163. After push succeeds → Save event to database → Remove from purgatory
80 117
81See [`src/git/authorization.rs:51-162`](../../src/git/authorization.rs) for implementation. 118See [`src/git/authorization.rs`](../../src/git/authorization.rs) for implementation.
119
120### 6. Announcement Purgatory: Bare Repo Created Immediately
121
122**Decision:** Create the bare git repo when announcement enters purgatory.
123
124**Why:** Git pushes may arrive at any time. Without a repo, pushes fail.
125
126**Consequence:** We allocate disk space for repos that may expire unused. Must delete repos on expiry.
127
128### 7. Replacement Announcements Skip Purgatory
129
130**Decision:** Announcements replacing an existing active (database) announcement are accepted immediately.
131
132**Why:** The repository is already proven active with content.
133
134**How:** Check if active announcement exists for `(pubkey, identifier)` before routing to purgatory.
82 135
83--- 136---
84 137
@@ -103,22 +156,54 @@ pub struct RefUpdate {
103} 156}
104``` 157```
105 158
159### Announcement Purgatory Entry
160
161```rust
162pub struct AnnouncementPurgatoryEntry {
163 /// The kind 30617 announcement event
164 pub event: Event,
165
166 /// Repository identifier from 'd' tag
167 pub identifier: String,
168
169 /// Event author pubkey
170 pub owner: PublicKey,
171
172 /// Path to the bare git repo on disk (created immediately on entry)
173 pub repo_path: PathBuf,
174
175 /// Relay URLs from 'relays'/'clone' tags — for sync registration
176 pub relays: HashSet<String>,
177
178 /// When added to purgatory
179 pub created_at: Instant,
180
181 /// Expiry deadline (30 min from creation, may be extended)
182 pub expires_at: Instant,
183
184 /// Whether the bare repo has been deleted (soft expiry phase)
185 pub soft_expired: bool,
186}
187```
188
189**Indexed by `(pubkey, identifier)`** because identifier is not unique across different owners.
190
106### State Purgatory Entry 191### State Purgatory Entry
107 192
108```rust 193```rust
109pub struct StatePurgatoryEntry { 194pub struct StatePurgatoryEntry {
110 /// The nostr state event (kind 30618) awaiting git data 195 /// The nostr state event (kind 30618) awaiting git data
111 pub event: Event, 196 pub event: Event,
112 197
113 /// Repository identifier from 'd' tag 198 /// Repository identifier from 'd' tag
114 pub identifier: String, 199 pub identifier: String,
115 200
116 /// Event author pubkey 201 /// Event author pubkey
117 pub author: PublicKey, 202 pub author: PublicKey,
118 203
119 /// When added to purgatory 204 /// When added to purgatory
120 pub created_at: Instant, 205 pub created_at: Instant,
121 206
122 /// Expiry deadline (30 min from creation, may be extended) 207 /// Expiry deadline (30 min from creation, may be extended)
123 pub expires_at: Instant, 208 pub expires_at: Instant,
124} 209}
@@ -132,14 +217,14 @@ pub struct StatePurgatoryEntry {
132pub struct PrPurgatoryEntry { 217pub struct PrPurgatoryEntry {
133 /// The nostr PR event, if received (None = git data arrived first) 218 /// The nostr PR event, if received (None = git data arrived first)
134 pub event: Option<Event>, 219 pub event: Option<Event>,
135 220
136 /// Expected commit SHA from 'c' tag (if event exists) 221 /// Expected commit SHA from 'c' tag (if event exists)
137 /// or actual commit pushed (if git arrived first) 222 /// or actual commit pushed (if git arrived first)
138 pub commit: String, 223 pub commit: String,
139 224
140 /// When added to purgatory 225 /// When added to purgatory
141 pub created_at: Instant, 226 pub created_at: Instant,
142 227
143 /// Expiry deadline (30 min from creation) 228 /// Expiry deadline (30 min from creation)
144 pub expires_at: Instant, 229 pub expires_at: Instant,
145} 230}
@@ -151,24 +236,180 @@ pub struct PrPurgatoryEntry {
151 236
152```rust 237```rust
153pub struct Purgatory { 238pub struct Purgatory {
239 /// Announcement events indexed by (owner, identifier)
240 announcement_purgatory: DashMap<(PublicKey, String), AnnouncementPurgatoryEntry>,
241
154 /// State events indexed by identifier (d tag) 242 /// State events indexed by identifier (d tag)
155 /// Multiple state events per identifier allowed (different authors) 243 /// Multiple state events per identifier allowed (different authors)
156 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>, 244 state_events: DashMap<String, Vec<StatePurgatoryEntry>>,
157 245
158 /// PR events indexed by event_id (hex string) 246 /// PR events indexed by event_id (hex string)
159 /// Single entry per event ID 247 /// Single entry per event ID
160 pr_events: Arc<DashMap<String, PrPurgatoryEntry>>, 248 pr_events: DashMap<String, PrPurgatoryEntry>,
161 249
162 /// Sync queue for background git data fetching 250 /// Sync queue for background git data fetching
163 sync_queue: Arc<DashMap<String, SyncQueueEntry>>, 251 sync_queue: DashMap<String, SyncQueueEntry>,
164 252
165 _git_data_path: PathBuf, 253 /// Events that previously expired without git data (prevents re-sync loops)
254 expired_events: DashMap<EventId, Instant>,
255}
256```
257
258### Persistence State (Disk Format)
259
260`Instant` fields cannot be serialized directly. Each entry type has a corresponding `Serializable*` wrapper that stores time fields as `u64` second offsets from a `saved_at: SystemTime` reference point. On restore, elapsed downtime is subtracted to produce the correct remaining TTL.
261
262```rust
263struct PurgatoryState {
264 version: u32, // currently 1
265 saved_at: SystemTime, // reference for offset math
266
267 /// Non-soft-expired announcements indexed by "owner_hex:identifier"
268 announcement_purgatory: HashMap<String, SerializableAnnouncementPurgatoryEntry>,
269
270 /// State events indexed by repository identifier
271 state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>,
272
273 /// PR events (and placeholders) indexed by event ID hex
274 pr_events: HashMap<String, SerializablePrPurgatoryEntry>,
275
276 /// Expired event IDs → approximate expiry SystemTime
277 expired_events: HashMap<String, SystemTime>,
166} 278}
167``` 279```
168 280
281The `announcement_purgatory` field uses `#[serde(default)]` so that state files written before announcement persistence was added (version 1 without the field) still deserialize correctly.
282
283---
284
285## Announcement Purgatory Flows
286
287### New Announcement Flow
288
289```
290Announcement arrives
291 |
292 v
293Is there an active announcement for (pubkey, identifier) in DB?
294 |
295 +-- YES --> Accept immediately (replacement, repo already proven)
296 |
297 +-- NO --> Is there a purgatory entry for (pubkey, identifier)?
298 |
299 +-- YES --> Replace purgatory entry, extend expiry 30 min
300 | Return OK to client (but don't serve)
301 |
302 +-- NO --> Create bare repo
303 Add to purgatory
304 Return OK to client (but don't serve)
305```
306
307### Git Data Arrival → Promotion
308
309```
310Git push/fetch completes with data
311 |
312 v
313process_purgatory_announcements() called
314 |
315 v
316Is there a purgatory announcement for (owner, identifier)?
317 |
318 +-- YES --> promote_announcement() removes from purgatory
319 | Save event to database
320 | Notify WebSocket clients
321 | (Sync upgrades to Full automatically via SelfSubscriber)
322 |
323 +-- NO --> Normal processing
324```
325
326### State Event Arrival for Purgatory Announcement
327
328```
329State event arrives
330 |
331 v
332fetch_repository_data_with_purgatory() checks DB + purgatory
333 |
334 +-- Announcement found in purgatory -->
335 | Validate authorization against purgatory announcement
336 | Extend purgatory announcement expiry (reset 30-min timer)
337 | If soft-expired: recreate bare repo, clear soft_expired flag
338 | Route state event to state purgatory
339 |
340 +-- No announcement anywhere --> Reject
341```
342
343### Announcement Expiry (Two-Phase Soft Expiry)
344
345The protocol specifies 30-minute expiry for announcements. We implement a two-phase soft expiry:
346
347**Phase 1 — Initial 30-minute expiry (`soft_expired == false`):**
348- Delete the bare git repo (frees disk space, respects protocol expiry)
349- Set `soft_expired = true`
350- Extend `expires_at` by 24 hours (`SOFT_EXPIRY_EXTENDED`)
351- Continue syncing state events (same as active purgatory)
352
353**Phase 2 — 24-hour soft expiry (`soft_expired == true`):**
354- Add event ID to `expired_events` (prevents re-sync loops)
355- Remove entry completely from `announcement_purgatory`
356
357**Why soft expiry?** Without it, we'd face a dilemma:
358
359- Add expired announcements to `failed_events` → permanently reject future state events, losing potential revival when state events arrive late
360- Re-fetch the announcement event on every sync cycle → wasting bandwidth and creating unnecessary sync traffic
361
362Soft expiry retains the event for 24 hours so that late-arriving state events (e.g. from a slow sync) can revive the announcement without forcing a full re-announcement flow.
363
364**Revival:** If a state event arrives for a soft-expired announcement, `extend_announcement_expiry()` recreates the bare repo, clears `soft_expired`, and resets the 30-minute timer.
365
366### Expiry Extension Triggers
367
368The 30-minute purgatory timer is reset (extended) in three scenarios:
369
370| Trigger | Location | Why |
371|---------|----------|-----|
372| State event arrives | `StatePolicy::process_state_event()` | Repo is actively receiving metadata |
373| Git push authorized against purgatory state | `get_state_authorization_for_specific_owner_repo()` | Repo is actively receiving git data |
374| Replacement announcement arrives | `AnnouncementPolicy::validate()` | Announcement updated |
375
376All three call `purgatory.extend_announcement_expiry(owner, identifier, 1800s)`.
377
378### Purgatory Lifecycle
379
380```
381 ┌─────────────────────────────────────┐
382 │ │
383 v │
384Announcement ──> ACTIVE ──────────────────────────────────┤
385 arrives (bare repo exists) │
386 │ │
387 ├── Git data ──> PROMOTED (exit) │
388 │ │
389 ├── Deletion ──> REMOVED (exit) │
390 │ │
391 v │
392 SOFT_EXPIRED ──────────────────────────────┘
393 (bare repo deleted, ^
394 event retained) │
395 │ │
396 ├── State event arrives (revival)
397
398 └── Extended expiry ──> REMOVED (exit)
399```
400
401| Exit | Trigger | Action |
402|------|---------|--------|
403| **Promotion** | Git data arrives | Move to database, sync upgrades to Full |
404| **Soft expiry** | Initial 30-min timeout | Delete bare repo, retain event, continue sync |
405| **Full expiry** | 24-hour soft expiry | Add to expired_events, remove from purgatory |
406| **Deletion** | Kind 5 event | Delete bare repo, remove from purgatory |
407| **Replacement** | Newer announcement (same pubkey, identifier) | Replace entry, extend expiry |
408| **Service change** | Newer announcement removes our service | Remove from purgatory |
409
169--- 410---
170 411
171## Event Flows 412## State and PR Event Flows
172 413
173### State Event Arrival (Kind 30618) 414### State Event Arrival (Kind 30618)
174 415
@@ -377,11 +618,12 @@ Purgatory includes a background sync system that fetches git data from remote se
377 618
378┌─────────────────────────────────────────────────────┐ 619┌─────────────────────────────────────────────────────┐
379│ process_newly_available_git_data(repo, oids) │ 620│ process_newly_available_git_data(repo, oids) │
380│ 1. Find satisfiable state events in purgatory │ 621│ 1. Find satisfiable announcement in purgatory │
381│ 2. Find satisfiable PR events in purgatory │ 622│ 2. Find satisfiable state events in purgatory │
382│ 3. Save events to database │ 623│ 3. Find satisfiable PR events in purgatory │
383│ 4. Sync git data to other owner repos │ 624│ 4. Save events to database │
384│ 5. Remove from purgatory │ 625│ 5. Sync git data to other owner repos │
626│ 6. Remove from purgatory │
385└─────────────────────────────────────────────────────┘ 627└─────────────────────────────────────────────────────┘
386``` 628```
387 629
@@ -402,8 +644,8 @@ pub struct SyncQueueEntry {
402 644
403**Backoff strategy:** 645**Backoff strategy:**
404- First attempt: 20 seconds 646- First attempt: 20 seconds
405- Second attempt: 2 minutes 647- Second attempt: 40 seconds
406- Subsequent attempts: 2 minutes 648- Subsequent attempts: capped at 2 minutes
407 649
408### Sync Delays 650### Sync Delays
409 651
@@ -428,7 +670,7 @@ pub struct ThrottleManager {
428``` 670```
429 671
430**Rate limiting:** 672**Rate limiting:**
431- Default: 5 requests per domain per 30 seconds 673- Default: 5 concurrent requests per domain, 30 requests per minute
432- Tracks request timestamps in a sliding window 674- Tracks request timestamps in a sliding window
433- Queues identifiers when domain is throttled 675- Queues identifiers when domain is throttled
434- Processes queue when capacity frees up 676- Processes queue when capacity frees up
@@ -439,7 +681,47 @@ See [`src/purgatory/sync/throttle.rs`](../../src/purgatory/sync/throttle.rs) for
439 681
440## Purgatory API 682## Purgatory API
441 683
442### Adding Entries 684### Announcement Purgatory
685
686```rust
687impl Purgatory {
688 /// Add an announcement to purgatory (bare repo already created by caller)
689 pub fn add_announcement(
690 &self,
691 event: Event,
692 identifier: String,
693 owner: PublicKey,
694 repo_path: PathBuf,
695 relays: HashSet<String>,
696 );
697
698 /// Promote announcement: remove from purgatory, return event for DB save
699 pub fn promote_announcement(
700 &self,
701 owner: &PublicKey,
702 identifier: &str,
703 ) -> Option<Event>;
704
705 /// Get announcements by identifier (for authorization checks)
706 pub fn get_announcements_by_identifier(
707 &self,
708 identifier: &str,
709 ) -> Vec<AnnouncementPurgatoryEntry>;
710
711 /// Extend expiry (and revive soft-expired entries, recreating bare repo)
712 pub fn extend_announcement_expiry(
713 &self,
714 owner: &PublicKey,
715 identifier: &str,
716 duration: Duration,
717 );
718
719 /// Get all announcements for sync registration
720 pub fn announcements_for_sync(&self) -> Vec<AnnouncementPurgatoryEntry>;
721}
722```
723
724### State and PR Purgatory
443 725
444```rust 726```rust
445impl Purgatory { 727impl Purgatory {
@@ -453,13 +735,7 @@ impl Purgatory {
453 735
454 /// Add a PR placeholder (git-data-first scenario) 736 /// Add a PR placeholder (git-data-first scenario)
455 pub fn add_pr_placeholder(&self, event_id: String, commit: String); 737 pub fn add_pr_placeholder(&self, event_id: String, commit: String);
456}
457```
458
459### Finding Entries
460 738
461```rust
462impl Purgatory {
463 /// Find state events waiting for an identifier 739 /// Find state events waiting for an identifier
464 pub fn find_state(&self, identifier: &str) -> Vec<StatePurgatoryEntry>; 740 pub fn find_state(&self, identifier: &str) -> Vec<StatePurgatoryEntry>;
465 741
@@ -476,13 +752,7 @@ impl Purgatory {
476 752
477 /// Find a PR placeholder specifically (git-data-first) 753 /// Find a PR placeholder specifically (git-data-first)
478 pub fn find_pr_placeholder(&self, event_id: &str) -> Option<String>; 754 pub fn find_pr_placeholder(&self, event_id: &str) -> Option<String>;
479}
480```
481
482### Removing Entries
483 755
484```rust
485impl Purgatory {
486 /// Remove all state events for an identifier 756 /// Remove all state events for an identifier
487 pub fn remove_state(&self, identifier: &str); 757 pub fn remove_state(&self, identifier: &str);
488 758
@@ -499,36 +769,14 @@ impl Purgatory {
499```rust 769```rust
500impl Purgatory { 770impl Purgatory {
501 /// Remove expired entries (called every 60 seconds) 771 /// Remove expired entries (called every 60 seconds)
502 /// Returns (state_removed, pr_removed) 772 /// Handles two-phase soft expiry for announcements
503 pub fn cleanup(&self) -> (usize, usize); 773 pub fn cleanup(&self);
504 774
505 /// Extend expiry for entries about to be processed 775 /// Extend expiry for state/PR entries about to be processed
506 /// Ensures at least `duration` remaining
507 pub fn extend_expiry(&self, identifier: &str, event_ids: &[EventId], duration: Duration); 776 pub fn extend_expiry(&self, identifier: &str, event_ids: &[EventId], duration: Duration);
508 777
509 /// Get current counts for metrics 778 /// Check if an event previously expired (prevents re-sync loops)
510 pub fn count(&self) -> (usize, usize); 779 pub fn is_expired(&self, event_id: &EventId) -> bool;
511}
512```
513
514### Sync Queue Management
515
516```rust
517impl Purgatory {
518 /// Enqueue identifier for sync with custom delay
519 pub fn enqueue_sync(&self, identifier: &str, delay: Duration);
520
521 /// Enqueue with default delay (3 minutes)
522 pub fn enqueue_sync_default(&self, identifier: &str);
523
524 /// Enqueue with immediate delay (500ms)
525 pub fn enqueue_sync_immediate(&self, identifier: &str);
526
527 /// Check if identifier has pending events
528 pub fn has_pending_events(&self, identifier: &str) -> bool;
529
530 /// Remove identifier from sync queue
531 pub fn remove_from_sync_queue(&self, identifier: &str);
532} 780}
533``` 781```
534 782
@@ -558,12 +806,6 @@ pub fn can_apply_state(
558 event: &Event, 806 event: &Event,
559 repo_path: &Path, 807 repo_path: &Path,
560) -> Result<bool>; 808) -> Result<bool>;
561
562/// Get refs from state that aren't being pushed
563pub fn get_unpushed_refs(
564 state_refs: &[RefPair],
565 pushed_refs: &[RefPair],
566) -> Vec<RefPair>;
567``` 809```
568 810
569See [`src/purgatory/helpers.rs`](../../src/purgatory/helpers.rs) for implementation. 811See [`src/purgatory/helpers.rs`](../../src/purgatory/helpers.rs) for implementation.
@@ -572,123 +814,37 @@ See [`src/purgatory/helpers.rs`](../../src/purgatory/helpers.rs) for implementat
572 814
573## Integration Points 815## Integration Points
574 816
575### 1. Event Policy (Nip34WritePolicy) 817### 1. Announcement Policy (`src/nostr/policy/announcement.rs`)
576 818
577State and PR events are added to purgatory when git data doesn't exist: 819Routes new announcements to purgatory or accepts replacements:
578 820
579```rust 821- If active DB announcement exists for `(pubkey, identifier)` → `Accept` immediately
580// From src/nostr/policy/state.rs 822- If purgatory entry exists → replace it, extend expiry, return `Accept`
581async fn handle_state(&self, event: &Event) -> WritePolicyResult { 823- Otherwise → return `AcceptPurgatory`, caller calls `add_to_purgatory()` which creates bare repo and adds to purgatory
582 let identifier = extract_identifier(event)?;
583
584 // Check if we have matching git data
585 if self.has_matching_git_data(&identifier, event).await? {
586 return WritePolicyResult::Accept;
587 }
588
589 // Add to purgatory
590 self.purgatory.add_state(
591 event.clone(),
592 identifier.clone(),
593 event.pubkey,
594 );
595
596 WritePolicyResult::Reject {
597 status: true, // Client sees OK
598 message: "purgatory: awaiting git data".into()
599 }
600}
601```
602 824
603### 2. Git Push Authorization 825### 2. State Event Policy (`src/nostr/policy/state.rs`)
604 826
605Authorization checks both database and purgatory: 827Checks purgatory announcements for authorization and extends their expiry:
606 828
607```rust 829```rust
608// From src/git/authorization.rs 830// Fetch announcements from both DB and purgatory
609pub async fn authorize_push( 831let repo_data = fetch_repository_data_with_purgatory(db, purgatory, identifier).await?;
610 database: &SharedDatabase, 832
611 identifier: &str, 833// For each authorized owner with a purgatory announcement, extend expiry
612 owner_pubkey: &str, 834purgatory.extend_announcement_expiry(&owner_pk, &identifier, Duration::from_secs(1800));
613 request_body: &Bytes,
614 purgatory: &Arc<Purgatory>, // Critical!
615 repo_path: &std::path::Path,
616) -> anyhow::Result<AuthorizationResult> {
617 // Parse pushed refs
618 let pushed_refs = parse_pushed_refs(request_body);
619
620 // Check database for state events
621 let db_result = get_authorization_from_db(database, identifier).await?;
622
623 if !db_result.authorized {
624 // No state in database - check purgatory
625 let purgatory_result = get_state_authorization_for_specific_owner_repo(
626 database,
627 identifier,
628 owner_pubkey,
629 purgatory,
630 &pushed_refs,
631 repo_path,
632 ).await?;
633
634 return purgatory_result;
635 }
636
637 db_result
638}
639``` 835```
640 836
641### 3. Post-Push Processing 837### 3. Git Push Authorization (`src/git/authorization.rs`)
642 838
643After successful push, events from purgatory are saved to database: 839`fetch_repository_data_with_purgatory()` merges DB announcements with purgatory announcements for authorization. On successful authorization via purgatory state events, also extends announcement expiry.
644 840
645```rust 841### 4. Git Data Processing (`src/git/sync.rs`)
646// From src/git/handlers.rs
647if from_purgatory {
648 if let (Some(db), Some(purg)) = (&database, &purgatory) {
649 // Save state event to database
650 db.save_event(&state.event).await?;
651
652 // Remove from purgatory
653 purg.remove_state_event(identifier, &state.event.id);
654 }
655}
656```
657 842
658### 4. Background Sync Loop 843`process_purgatory_announcements()` is called after any git push or background sync fetch. It promotes announcements from purgatory to the database and notifies WebSocket clients.
659 844
660Started during application initialization: 845### 5. Sync Registration (`src/sync/`)
661 846
662```rust 847A background timer (`run_purgatory_announcement_sync`, every 5 seconds) ensures purgatory announcements are registered in `RepoSyncIndex` with `SyncLevel::StateOnly`. When an announcement is promoted, the `SelfSubscriber` upgrades it to `SyncLevel::Full`.
663// From src/main.rs
664let purgatory = Arc::new(Purgatory::new(git_data_path));
665let ctx = Arc::new(RealSyncContext::new(
666 database.clone(),
667 purgatory.clone(),
668 config.domain.clone(),
669 git_data_path.clone(),
670));
671let throttle_manager = Arc::new(ThrottleManager::new(5, 30));
672throttle_manager.set_context(ctx.clone());
673
674// Start sync loop
675let sync_handle = purgatory.clone().start_sync_loop(ctx, throttle_manager);
676
677// Start cleanup task
678let cleanup_handle = tokio::spawn(async move {
679 let mut interval = tokio::time::interval(Duration::from_secs(60));
680 loop {
681 interval.tick().await;
682 let (state_removed, pr_removed) = purgatory.cleanup();
683 if state_removed + pr_removed > 0 {
684 tracing::debug!(
685 "Purgatory cleanup removed {} state, {} PR entries",
686 state_removed, pr_removed
687 );
688 }
689 }
690});
691```
692 848
693--- 849---
694 850
@@ -697,8 +853,9 @@ let cleanup_handle = tokio::spawn(async move {
697``` 853```
698src/ 854src/
699├── purgatory/ 855├── purgatory/
700│ ├── mod.rs # Main Purgatory struct and API 856│ ├── mod.rs # Main Purgatory struct, API, save_to_disk, restore_from_disk
701│ ├── types.rs # RefPair, StatePurgatoryEntry, PrPurgatoryEntry 857│ ├── types.rs # RefPair, AnnouncementPurgatoryEntry, StatePurgatoryEntry, PrPurgatoryEntry
858│ ├── persistence.rs # instant_to_offset / offset_to_instant time conversion utilities
702│ ├── helpers.rs # Ref extraction and matching functions 859│ ├── helpers.rs # Ref extraction and matching functions
703│ └── sync/ 860│ └── sync/
704│ ├── mod.rs # Sync module exports 861│ ├── mod.rs # Sync module exports
@@ -710,9 +867,10 @@ src/
710├── git/ 867├── git/
711│ ├── authorization.rs # authorize_push with purgatory checking 868│ ├── authorization.rs # authorize_push with purgatory checking
712│ ├── handlers.rs # handle_receive_pack with post-push processing 869│ ├── handlers.rs # handle_receive_pack with post-push processing
713│ └── sync.rs # process_newly_available_git_data 870│ └── sync.rs # process_newly_available_git_data, process_purgatory_announcements
714└── nostr/ 871└── nostr/
715 └── policy/ 872 └── policy/
873 ├── announcement.rs # Route announcements to purgatory
716 ├── state.rs # State event policy with purgatory 874 ├── state.rs # State event policy with purgatory
717 └── pr_event.rs # PR event policy with purgatory 875 └── pr_event.rs # PR event policy with purgatory
718``` 876```
@@ -725,7 +883,8 @@ src/
725 883
726Located in each module: 884Located in each module:
727 885
728- **[`src/purgatory/mod.rs`](../../src/purgatory/mod.rs)** - Core purgatory operations 886- **[`src/purgatory/mod.rs`](../../src/purgatory/mod.rs)** - Core purgatory operations including announcement purgatory; persistence round-trip tests for all entry types (state, PR, announcement, expired events, downtime calculation, soft-expired exclusion, missing-repo skip)
887- **[`src/purgatory/persistence.rs`](../../src/purgatory/persistence.rs)** - `instant_to_offset` / `offset_to_instant` round-trip tests
729- **[`src/purgatory/helpers.rs`](../../src/purgatory/helpers.rs)** - Ref matching logic 888- **[`src/purgatory/helpers.rs`](../../src/purgatory/helpers.rs)** - Ref matching logic
730- **[`src/purgatory/sync/functions.rs`](../../src/purgatory/sync/functions.rs)** - Sync functions with MockSyncContext 889- **[`src/purgatory/sync/functions.rs`](../../src/purgatory/sync/functions.rs)** - Sync functions with MockSyncContext
731- **[`src/purgatory/sync/throttle.rs`](../../src/purgatory/sync/throttle.rs)** - Throttle manager 890- **[`src/purgatory/sync/throttle.rs`](../../src/purgatory/sync/throttle.rs)** - Throttle manager
@@ -734,17 +893,33 @@ Located in each module:
734 893
735Located in [`tests/`](../../tests/): 894Located in [`tests/`](../../tests/):
736 895
896- **Announcement purgatory flow** - Announcement enters purgatory, git data promotes it
897- **Announcement soft expiry** - Bare repo deleted after 30 min, event retained 24h
898- **Announcement revival** - State event revives soft-expired announcement
737- **State event purgatory flow** - Event arrives, git push releases it 899- **State event purgatory flow** - Event arrives, git push releases it
738- **PR event purgatory flow** - Event arrives, git push releases it 900- **PR event purgatory flow** - Event arrives, git push releases it
739- **Git-data-first flow** - Git push creates placeholder, event completes it 901- **Git-data-first flow** - Git push creates placeholder, event completes it
740- **Authorization with purgatory** - Push authorized by purgatory state 902- **Authorization with purgatory** - Push authorized by purgatory state
741- **Background sync** - Sync fetches git data and releases events 903- **Background sync** - Sync fetches git data and releases events
904- **Persistence across restart** - Save/restore cycle preserves all entry types including announcements
742 905
743--- 906---
744 907
745## Key Learnings 908## Key Learnings
746 909
747### 1. Purgatory Authorization is Critical 910### 1. Announcement Purgatory Prevents Misleading Empty Repos
911
912Without announcement purgatory, we'd serve announcements for repos with no content. Clients would see the announcement, try to clone, and get nothing.
913
914**Solution:** Announcements wait in purgatory until git data proves content exists.
915
916### 2. Soft Expiry Avoids Sync Loops
917
918The protocol's 30-minute expiry creates a problem: without soft expiry, we'd either permanently block repositories or constantly re-sync expired announcement events.
919
920**Solution:** Soft expiry retains the event for 24 hours after deleting the bare repo, allowing revival without re-fetching.
921
922### 3. Purgatory Authorization is Critical
748 923
749Without checking purgatory during authorization, we have a deadlock: 924Without checking purgatory during authorization, we have a deadlock:
750- State event goes to purgatory (no git data) 925- State event goes to purgatory (no git data)
@@ -753,7 +928,7 @@ Without checking purgatory during authorization, we have a deadlock:
753 928
754**Solution:** `authorize_push()` checks both database and purgatory. 929**Solution:** `authorize_push()` checks both database and purgatory.
755 930
756### 2. Late Binding for State Events 931### 4. Late Binding for State Events
757 932
758Extracting refs at event arrival time doesn't work when: 933Extracting refs at event arrival time doesn't work when:
759- Multiple state events arrive for same identifier 934- Multiple state events arrive for same identifier
@@ -761,7 +936,7 @@ Extracting refs at event arrival time doesn't work when:
761 936
762**Solution:** Extract and match refs at push time via `find_matching_states()`. 937**Solution:** Extract and match refs at push time via `find_matching_states()`.
763 938
764### 3. Bidirectional Waiting for PR Events 939### 5. Bidirectional Waiting for PR Events
765 940
766PR events can arrive before or after git data: 941PR events can arrive before or after git data:
767- Event first → Wait for git push 942- Event first → Wait for git push
@@ -769,26 +944,21 @@ PR events can arrive before or after git data:
769 944
770**Solution:** `PrPurgatoryEntry.event: Option<Event>` with `None` = placeholder. 945**Solution:** `PrPurgatoryEntry.event: Option<Event>` with `None` = placeholder.
771 946
772### 4. Sync Queue Debouncing 947### 6. Persistence Requires Instant → Duration Conversion
773
774When events arrive in bursts (e.g., negentropy sync), we don't want to spawn a sync task for each event.
775
776**Solution:** `enqueue_sync()` resets `attempt_count` and updates `next_attempt` if already queued.
777 948
778### 5. Domain Throttling with Queues 949`std::time::Instant` is not serializable and is not meaningful across process boundaries. Expiry deadlines must be converted to a portable form.
779 950
780When a domain is throttled, we still want to eventually sync from it. 951**Solution:** Store each deadline as a `u64` second offset from a `saved_at: SystemTime` reference. On restore, subtract elapsed downtime from each offset to compute the new `Instant`. Entries whose deadline already passed during downtime get `expires_at = now` and are swept by the next cleanup tick.
781 952
782**Solution:** `ThrottleManager` maintains per-domain queues and processes them when capacity frees. 953**Soft-expired announcements are excluded from persistence** because their bare repos have been deleted. Restoring them would leave purgatory entries pointing at non-existent repos. They are simply dropped; background sync will re-fetch the announcement event if needed.
783 954
784--- 955---
785 956
786## Related Documentation 957## Related Documentation
787 958
788- [Inline Authorization](inline-authorization.md) - Why purgatory checking during authorization is essential
789- [Architecture Overview](architecture.md) - Full system design 959- [Architecture Overview](architecture.md) - Full system design
790- [Background Sync](../how-to/purgatory-sync.md) - How to configure and monitor sync 960- [GRASP-02 Proactive Sync](grasp-02-proactive-sync.md) - Relay-to-relay event sync with SyncLevel
791- [Test Strategy](../reference/test-strategy.md) - How we test purgatory 961- [GRASP-02 Purgatory Git Data Fetching](grasp-02-proactive-sync-purgatory-git-data.md) - Background git data hunting
792 962
793--- 963---
794 964
diff --git a/grasp-audit/README.md b/grasp-audit/README.md
index 4d2401f..936f10f 100644
--- a/grasp-audit/README.md
+++ b/grasp-audit/README.md
@@ -245,7 +245,7 @@ pub async fn test_something(client: &AuditClient) -> TestResult {
245 let ctx = TestContext::new(client); 245 let ctx = TestContext::new(client);
246 246
247 // 2. Prerequisites (cached per-TestContext) 247 // 2. Prerequisites (cached per-TestContext)
248 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; 248 let repo = ctx.get_fixture(FixtureKind::ValidRepoSent).await?;
249 249
250 // 3. Test-specific event 250 // 3. Test-specific event
251 let my_event = client.create_issue(&repo, "Title", "Content", vec![])?; 251 let my_event = client.create_issue(&repo, "Title", "Content", vec![])?;
@@ -298,10 +298,10 @@ Fixtures use deterministic commit hashes for reproducible testing:
298 298
299| Constant | Hash | Used By | 299| Constant | Hash | Used By |
300| ------------------------------------------------ | ------------------------------------------ | ------------------------------------------------ | 300| ------------------------------------------------ | ------------------------------------------ | ------------------------------------------------ |
301| `DETERMINISTIC_COMMIT_HASH` | `64ea71d79a57a7acb334cd9651f8aec067c0ce5d` | Owner fixtures (RepoState, OwnerStateDataPushed) | 301| `DETERMINISTIC_COMMIT_HASH` | `d6e4b26ccf9c268d18d60e6d09804313cc850821` | Owner fixtures (RepoState, OwnerStateDataPushed) |
302| `MAINTAINER_DETERMINISTIC_COMMIT_HASH` | `1c2d472c9b71ed51968a66500281a3c4a6840464` | MaintainerStateDataPushed | 302| `MAINTAINER_DETERMINISTIC_COMMIT_HASH` | `d26703c007eff6d17fee3bb70ce8be5d1427d0e7` | MaintainerStateDataPushed |
303| `RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH` | `05939b82de66fbdb9c077d0a64fc68522f3cb8e0` | RecursiveMaintainerStateDataPushed | 303| `RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH` | `54a2b4b3cbc3373ad1438b8ffad1681d12bc6c4a` | RecursiveMaintainerStateDataPushed |
304| `PR_TEST_COMMIT_HASH` | `5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb` | PR fixtures (PREvent, PREventGenerated) | 304| `PR_TEST_COMMIT_HASH` | `5a51b30e4615b572dcd5b9e487861b58605a5c21` | PR fixtures (PREvent, PREventGenerated) |
305 305
306#### Fixture Dependencies 306#### Fixture Dependencies
307 307
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs
index 91a93dc..5c263ad 100644
--- a/grasp-audit/src/client.rs
+++ b/grasp-audit/src/client.rs
@@ -209,6 +209,36 @@ impl AuditClient {
209 Ok(event_id) 209 Ok(event_id)
210 } 210 }
211 211
212 /// Send event and note whether it entered purgatory (not served) or was served immediately.
213 ///
214 /// This is a tolerant version of `send_event_expect_purgatory_not_served` that doesn't
215 /// fail if purgatory is not observed. It returns whether purgatory was observed so
216 /// fixtures can proceed regardless of relay implementation status.
217 ///
218 /// Returns (EventId, bool) where bool = true if event was NOT served (purgatory observed).
219 pub async fn send_event_and_note_purgatory(&self, event: Event) -> Result<(EventId, bool)> {
220 if self.config.read_only {
221 return Err(anyhow!("Client is in read-only mode"));
222 }
223
224 let output = self.client.send_event(&event).await?;
225 let event_id = *output.id();
226
227 // Check if any relay rejected the event and return the error message
228 if !output.failed.is_empty() {
229 let (relay_url, error) = output.failed.iter().next().unwrap();
230 return Err(anyhow!("Relay {} rejected event: {}", relay_url, error));
231 }
232
233 // Wait a bit for event to propagate
234 tokio::time::sleep(Duration::from_millis(300)).await;
235
236 // Check if event is served (not in purgatory) or not served (in purgatory)
237 let in_purgatory = !self.is_event_on_relay(event.id).await?;
238
239 Ok((event_id, in_purgatory))
240 }
241
212 /// check if an event is on the relay 242 /// check if an event is on the relay
213 pub async fn is_event_on_relay(&self, id: EventId) -> Result<bool> { 243 pub async fn is_event_on_relay(&self, id: EventId) -> Result<bool> {
214 Ok(!self 244 Ok(!self
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs
index bbc7740..45d3094 100644
--- a/grasp-audit/src/fixtures.rs
+++ b/grasp-audit/src/fixtures.rs
@@ -47,7 +47,7 @@
47//! let ctx = TestContext::new(&client); 47//! let ctx = TestContext::new(&client);
48//! 48//!
49//! // Request a fixture - behavior depends on mode 49//! // Request a fixture - behavior depends on mode
50//! let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; 50//! let repo = ctx.get_fixture(FixtureKind::ValidRepoSent).await?;
51//! # Ok(()) 51//! # Ok(())
52//! # } 52//! # }
53//! ``` 53//! ```
@@ -61,59 +61,68 @@ use std::sync::{Arc, Mutex};
61/// Deterministic commit hash used in RepoState fixtures (Owner variant) 61/// Deterministic commit hash used in RepoState fixtures (Owner variant)
62/// This is the hash produced by creating a commit with: 62/// This is the hash produced by creating a commit with:
63/// - Message: "Initial commit" 63/// - Message: "Initial commit"
64/// - File: test.txt containing "Initial commit" 64/// - File: test.txt containing "Initial commit\n" (with trailing newline)
65/// - Author date: 2024-01-01T00:00:00Z 65/// - Author date: 2024-01-01T00:00:00Z
66/// - Committer date: 2024-01-01T00:00:00Z 66/// - Committer date: 2024-01-01T00:00:00Z
67/// - GPG signing: disabled 67/// - GPG signing: disabled
68/// - User: "GRASP Audit Test <test@grasp-audit.local>" 68/// - User: "GRASP Audit Test <test@grasp-audit.local>"
69/// - Parent: Initial empty commit (09cc37de80f3434fa98864a86730b8d7777bd6ae) 69/// - Parent: none (root commit)
70pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c0ce5d"; 70pub const DETERMINISTIC_COMMIT_HASH: &str = "d6e4b26ccf9c268d18d60e6d09804313cc850821";
71 71
72/// Deterministic commit hash for maintainer fixtures (Maintainer variant) 72/// Deterministic commit hash for maintainer fixtures (Maintainer variant)
73/// This is the hash produced by creating a commit with: 73/// This is the hash produced by creating a commit with:
74/// - Message: "Maintainer initial commit" 74/// - Message: "Maintainer initial commit"
75/// - File: test.txt containing "Maintainer initial commit" 75/// - File: test.txt containing "Maintainer initial commit\n" (with trailing newline)
76/// - Author date: 2024-01-01T00:00:00Z 76/// - Author date: 2024-01-01T00:00:00Z
77/// - Committer date: 2024-01-01T00:00:00Z 77/// - Committer date: 2024-01-01T00:00:00Z
78/// - GPG signing: disabled 78/// - GPG signing: disabled
79/// - User: "GRASP Audit Test <test@grasp-audit.local>" 79/// - User: "GRASP Audit Test <test@grasp-audit.local>"
80/// - Parent: none (root commit) 80/// - Parent: none (root commit)
81/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content 81pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "d26703c007eff6d17fee3bb70ce8be5d1427d0e7";
82pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464";
83 82
84/// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant) 83/// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant)
85/// This is the hash produced by creating a commit with: 84/// This is the hash produced by creating a commit with:
86/// - Message: "Recursive maintainer initial commit" 85/// - Message: "Recursive maintainer initial commit"
87/// - File: test.txt containing "Recursive maintainer initial commit" 86/// - File: test.txt containing "Recursive maintainer initial commit\n" (with trailing newline)
88/// - Author date: 2024-01-01T00:00:00Z 87/// - Author date: 2024-01-01T00:00:00Z
89/// - Committer date: 2024-01-01T00:00:00Z 88/// - Committer date: 2024-01-01T00:00:00Z
90/// - GPG signing: disabled 89/// - GPG signing: disabled
91/// - User: "GRASP Audit Test <test@grasp-audit.local>" 90/// - User: "GRASP Audit Test <test@grasp-audit.local>"
92/// - Parent: none (root commit) 91/// - Parent: none (root commit)
93/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content
94pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = 92pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str =
95 "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; 93 "54a2b4b3cbc3373ad1438b8ffad1681d12bc6c4a";
96 94
97/// Deterministic commit hash for PR test fixtures (PRTestCommit variant) 95/// Deterministic commit hash for PR test fixtures (PRTestCommit variant)
98/// This is the hash produced by creating a commit with: 96/// This is the hash produced by creating a commit with:
99/// - Message: "PR test deterministic commit" 97/// - Message: "PR test deterministic commit"
100/// - File: test.txt containing "PR test deterministic commit" 98/// - File: test.txt containing "PR test deterministic commit\n" (with trailing newline)
99/// - Author date: 2024-01-01T00:00:00Z
100/// - Committer date: 2024-01-01T00:00:00Z
101/// - GPG signing: disabled
102/// - User: "GRASP Audit Test <test@grasp-audit.local>"
103/// - Parent: none (root commit)
104pub const PR_TEST_COMMIT_HASH: &str = "5a51b30e4615b572dcd5b9e487861b58605a5c21";
105
106/// Deterministic commit hash for second PR test fixtures (PRTestCommit2 variant)
107/// This is the hash produced by creating a commit with:
108/// - Message: "PR test deterministic commit 2"
109/// - File: test.txt containing "PR test deterministic commit 2\n" (with trailing newline)
101/// - Author date: 2024-01-01T00:00:00Z 110/// - Author date: 2024-01-01T00:00:00Z
102/// - Committer date: 2024-01-01T00:00:00Z 111/// - Committer date: 2024-01-01T00:00:00Z
103/// - GPG signing: disabled 112/// - GPG signing: disabled
104/// - User: "GRASP Audit Test <test@grasp-audit.local>" 113/// - User: "GRASP Audit Test <test@grasp-audit.local>"
105/// - Parent: none (root commit) 114/// - Parent: none (root commit)
106pub const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb"; 115pub const PR_TEST_COMMIT_HASH_2: &str = "99420bc57835f5bc8ca20ab21a8d12850043920e";
107 116
108/// Types of test fixtures available 117/// Types of test fixtures available
109/// 118///
110/// ## Fixture Dependencies 119/// ## Fixture Dependencies
111/// 120///
112/// Several fixtures depend on `ValidRepo` - they all use the SAME repo_id 121/// Several fixtures depend on `ValidRepoSent` - they all use the SAME repo_id
113/// within a single TestContext instance to ensure proper fixture relationships: 122/// within a single TestContext instance to ensure proper fixture relationships:
114/// - `RepoState` → uses ValidRepo's repo_id 123/// - `RepoState` → uses ValidRepoSent's repo_id
115/// - `MaintainerAnnouncement` + `MaintainerState` → uses ValidRepo's repo_id 124/// - `MaintainerAnnouncement` + `MaintainerState` → uses ValidRepoSent's repo_id
116/// - `RecursiveMaintainerRepoAndState` → uses ValidRepo's repo_id 125/// - `RecursiveMaintainerRepoAndState` → uses ValidRepoSent's repo_id
117/// 126///
118/// This enables testing recursive maintainer authorization chains where multiple 127/// This enables testing recursive maintainer authorization chains where multiple
119/// parties publish announcements and state events for the same repository. 128/// parties publish announcements and state events for the same repository.
@@ -122,10 +131,16 @@ pub enum FixtureKind {
122 /// Basic repository announcement (kind 30617) 131 /// Basic repository announcement (kind 30617)
123 /// - Signed by owner keys (`client.keys()`) 132 /// - Signed by owner keys (`client.keys()`)
124 /// - Lists `client.maintainer_pubkey_hex()` in maintainers tag 133 /// - Lists `client.maintainer_pubkey_hex()` in maintainers tag
125 ValidRepo, 134 ValidRepoSent,
135
136 /// Repository announcement that is queryable from the relay (served, not in purgatory)
137 /// - Depends on OwnerStateDataPushed (git data pushed, announcement promoted)
138 /// - Returns the same event as ValidRepoSent (now queryable)
139 /// - Use this for tests that need to query the announcement back from the relay
140 ValidRepoServed,
126 141
127 /// Repository with one issue (kind 1621) 142 /// Repository with one issue (kind 1621)
128 /// - Requires ValidRepo (reuses same repo_id) 143 /// - Requires ValidRepoServed (needs queryable repo for issue to reference)
129 RepoWithIssue, 144 RepoWithIssue,
130 145
131 /// Repository with issue and comment (kind 1111) 146 /// Repository with issue and comment (kind 1111)
@@ -133,14 +148,30 @@ pub enum FixtureKind {
133 RepoWithComment, 148 RepoWithComment,
134 149
135 /// Repository state announcement (kind 30618) for owner 150 /// Repository state announcement (kind 30618) for owner
136 /// - Requires ValidRepo (uses same repo_id) 151 /// - Requires ValidRepoSent (uses same repo_id)
137 /// - Signed by owner keys (`client.keys()`) 152 /// - Signed by owner keys (`client.keys()`)
138 /// - Points to DETERMINISTIC_COMMIT_HASH 153 /// - Points to DETERMINISTIC_COMMIT_HASH
139 /// - Timestamp: 10 seconds in the past 154 /// - Timestamp: 10 seconds in the past
140 RepoState, 155 RepoState,
141 156
142 /// PR (Pull Request) event for the SAME repo_id as ValidRepo 157 /// Owner's repository state announcement (kind 30618) sent to relay and accepted into purgatory
143 /// - Requires ValidRepo (uses same repo_id) 158 ///
159 /// This is the "sent" stage: the state event has been published to the relay and
160 /// accepted (OK response), but no git data has been pushed yet so it remains in
161 /// purgatory and is not served to clients.
162 ///
163 /// Use this when you need the state event to exist on the relay but do not need
164 /// the full push/serve cycle. For the complete cycle (git pushed + verified served),
165 /// use `OwnerStateDataPushed`.
166 ///
167 /// - Requires ValidRepoSent (uses same repo_id)
168 /// - Signed by owner keys (`client.keys()`)
169 /// - Points to DETERMINISTIC_COMMIT_HASH
170 /// - Timestamp: 10 seconds in the past
171 OwnerRepoStateSent,
172
173 /// PR (Pull Request) event for the SAME repo_id as ValidRepoServed
174 /// - Requires ValidRepoServed (uses same repo_id, needs queryable repo)
144 /// - Signed by `client.pr_author_keys()` 175 /// - Signed by `client.pr_author_keys()`
145 /// - Kind 1618 (NIP-34 PR) 176 /// - Kind 1618 (NIP-34 PR)
146 /// - Includes `a` tag referencing the repo 177 /// - Includes `a` tag referencing the repo
@@ -153,7 +184,7 @@ pub enum FixtureKind {
153 /// This is a "Generated" stage fixture - the event is created but not published. 184 /// This is a "Generated" stage fixture - the event is created but not published.
154 /// Useful for tests that need the PR event ID before the event exists on the relay. 185 /// Useful for tests that need the PR event ID before the event exists on the relay.
155 /// 186 ///
156 /// - Requires ValidRepo (uses same repo_id) 187 /// - Requires ValidRepoServed (uses same repo_id, needs queryable repo)
157 /// - Signed by `client.pr_author_keys()` 188 /// - Signed by `client.pr_author_keys()`
158 /// - Kind 1618 (NIP-34 PR) 189 /// - Kind 1618 (NIP-34 PR)
159 /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH 190 /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH
@@ -187,7 +218,7 @@ pub enum FixtureKind {
187 /// (the "wrong" commit), but no PR event exists yet on the relay. 218 /// (the "wrong" commit), but no PR event exists yet on the relay.
188 /// 219 ///
189 /// Server state after this fixture: 220 /// Server state after this fixture:
190 /// - ValidRepo announcement on relay 221 /// - ValidRepoServed announcement on relay (repo is queryable)
191 /// - refs/nostr/<pr-event-id> exists on git server with wrong commit 222 /// - refs/nostr/<pr-event-id> exists on git server with wrong commit
192 /// - PR event is NOT on relay (but returned for tests to publish later) 223 /// - PR event is NOT on relay (but returned for tests to publish later)
193 /// 224 ///
@@ -203,7 +234,7 @@ pub enum FixtureKind {
203 /// then the PR event was published (which may trigger cleanup). 234 /// then the PR event was published (which may trigger cleanup).
204 /// 235 ///
205 /// Server state after this fixture: 236 /// Server state after this fixture:
206 /// - ValidRepo announcement on relay 237 /// - ValidRepoServed announcement on relay
207 /// - PR event is on relay 238 /// - PR event is on relay
208 /// - refs/nostr/<pr-event-id> may have been cleaned up (that's what tests verify) 239 /// - refs/nostr/<pr-event-id> may have been cleaned up (that's what tests verify)
209 /// 240 ///
@@ -212,6 +243,50 @@ pub enum FixtureKind {
212 /// - Returns: the sent PR event 243 /// - Returns: the sent PR event
213 PREventSentAfterWrongPush, 244 PREventSentAfterWrongPush,
214 245
246 /// Second PR event generated (built) but NOT sent to relay
247 ///
248 /// Uses PR_TEST_COMMIT_HASH_2 (different from PR_TEST_COMMIT_HASH).
249 /// This allows testing purgatory mechanism with a separate PR event
250 /// that doesn't conflict with existing PR fixtures.
251 ///
252 /// - Requires ValidRepoServed (uses same repo_id, needs git data to exist)
253 /// - Signed by `client.pr_author_keys()`
254 /// - Kind 1618 (NIP-34 PR)
255 /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH_2
256 /// - NOT sent to relay
257 PREvent2Generated,
258
259 /// Second PR event sent to relay (enters purgatory)
260 ///
261 /// After this fixture:
262 /// - PR event is on relay but NOT served (in purgatory)
263 /// - No git data at refs/nostr/<pr-event-id>
264 ///
265 /// - Requires PREvent2Generated
266 /// - Sends the PR event to relay
267 /// - Returns: the sent PR event (in purgatory)
268 PREvent2Sent,
269
270 /// Git data pushed for second PR event AFTER event was sent
271 ///
272 /// After this fixture:
273 /// - PR event was in purgatory
274 /// - Correct commit pushed to refs/nostr/<pr-event-id>
275 /// - PR event should be released from purgatory
276 ///
277 /// - Requires PREvent2Sent
278 /// - Pushes correct commit (PR_TEST_COMMIT_HASH_2) to refs/nostr/<pr-event-id>
279 /// - Returns: the PR event (should now be served)
280 PREvent2GitDataPushed,
281
282 /// Full fixture: second PR event sent, git pushed, event served
283 ///
284 /// Combines PREvent2Sent + PREvent2GitDataPushed for convenience.
285 ///
286 /// - Requires PREvent2GitDataPushed
287 /// - Returns: the served PR event
288 PREvent2Served,
289
215 /// Owner's state event with git data successfully pushed (full 4-stage fixture) 290 /// Owner's state event with git data successfully pushed (full 4-stage fixture)
216 /// 291 ///
217 /// This fixture represents the complete flow for testing state push authorization: 292 /// This fixture represents the complete flow for testing state push authorization:
@@ -221,7 +296,7 @@ pub enum FixtureKind {
221 /// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay 296 /// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay
222 /// 5. **Verified**: Confirms event is served by relay 297 /// 5. **Verified**: Confirms event is served by relay
223 /// 298 ///
224 /// - Requires ValidRepo (uses same repo_id) 299 /// - Requires ValidRepoSent (uses same repo_id)
225 /// - State event signed by owner keys (`client.keys()`) 300 /// - State event signed by owner keys (`client.keys()`)
226 /// - Points to DETERMINISTIC_COMMIT_HASH 301 /// - Points to DETERMINISTIC_COMMIT_HASH
227 /// - Git push verified to succeed (state matches pushed commit) 302 /// - Git push verified to succeed (state matches pushed commit)
@@ -252,7 +327,7 @@ pub enum FixtureKind {
252 /// not the owner's announcement, so this tests the recursive maintainer traversal. 327 /// not the owner's announcement, so this tests the recursive maintainer traversal.
253 /// 328 ///
254 /// This fixture represents the complete flow for testing recursive maintainer push authorization: 329 /// This fixture represents the complete flow for testing recursive maintainer push authorization:
255 /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepo + OwnerStateDataPushed) 330 /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepoSent + OwnerStateDataPushed)
256 /// Creates MaintainerAnnouncement + RecursiveMaintainerState 331 /// Creates MaintainerAnnouncement + RecursiveMaintainerState
257 /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message) 332 /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message)
258 /// 3. **Verify Not Served**: Confirms event is not served by relays 333 /// 3. **Verify Not Served**: Confirms event is not served by relays
@@ -276,16 +351,29 @@ impl FixtureKind {
276 pub fn dependencies(&self) -> Vec<FixtureKind> { 351 pub fn dependencies(&self) -> Vec<FixtureKind> {
277 match self { 352 match self {
278 // Base fixtures - no dependencies 353 // Base fixtures - no dependencies
279 Self::ValidRepo => vec![], 354 Self::ValidRepoSent => vec![],
280 355
281 // Fixtures that depend on ValidRepo 356 // ValidRepoServed depends on OwnerStateDataPushed (announcement promoted after git push)
282 Self::RepoWithIssue => vec![Self::ValidRepo], 357 Self::ValidRepoServed => vec![Self::OwnerStateDataPushed],
283 Self::RepoState => vec![Self::ValidRepo], 358
284 Self::PREvent => vec![Self::ValidRepo], 359 // Fixtures that depend on ValidRepoServed (need queryable announcement)
285 Self::PREventGenerated => vec![Self::ValidRepo], 360 Self::RepoWithIssue => vec![Self::ValidRepoServed],
361 Self::RepoState => vec![Self::ValidRepoSent],
362 // OwnerRepoStateSent depends on ValidRepoSent: state event sent, sitting in purgatory
363 Self::OwnerRepoStateSent => vec![Self::ValidRepoSent],
364 Self::PREvent => vec![Self::ValidRepoServed],
365 Self::PREventGenerated => vec![Self::ValidRepoServed],
286 Self::PRWrongCommitPushedBeforeEvent => vec![Self::PREventGenerated], 366 Self::PRWrongCommitPushedBeforeEvent => vec![Self::PREventGenerated],
287 Self::PREventSentAfterWrongPush => vec![Self::PRWrongCommitPushedBeforeEvent], 367 Self::PREventSentAfterWrongPush => vec![Self::PRWrongCommitPushedBeforeEvent],
288 Self::OwnerStateDataPushed => vec![Self::ValidRepo], 368
369 // Second PR event fixtures (for purgatory testing)
370 Self::PREvent2Generated => vec![Self::ValidRepoServed],
371 Self::PREvent2Sent => vec![Self::PREvent2Generated],
372 Self::PREvent2GitDataPushed => vec![Self::PREvent2Sent],
373 Self::PREvent2Served => vec![Self::PREvent2GitDataPushed],
374
375 // OwnerStateDataPushed depends on OwnerRepoStateSent (git push + purgatory release)
376 Self::OwnerStateDataPushed => vec![Self::OwnerRepoStateSent],
289 377
290 // Fixtures that depend on RepoWithIssue 378 // Fixtures that depend on RepoWithIssue
291 Self::RepoWithComment => vec![Self::RepoWithIssue], 379 Self::RepoWithComment => vec![Self::RepoWithIssue],
@@ -321,8 +409,17 @@ impl FixtureKind {
321 Self::PRWrongCommitPushedBeforeEvent => true, 409 Self::PRWrongCommitPushedBeforeEvent => true,
322 // PREventSentAfterWrongPush sends the PR event internally 410 // PREventSentAfterWrongPush sends the PR event internally
323 Self::PREventSentAfterWrongPush => true, 411 Self::PREventSentAfterWrongPush => true,
412 // Second PR event fixtures handle their own events/git data
413 Self::PREvent2Generated => true,
414 Self::PREvent2Sent => true,
415 Self::PREvent2GitDataPushed => true,
416 Self::PREvent2Served => true,
324 // HeadSetToDevelopBranch sends its state event internally 417 // HeadSetToDevelopBranch sends its state event internally
325 Self::HeadSetToDevelopBranch => true, 418 Self::HeadSetToDevelopBranch => true,
419 // ValidRepoServed doesn't send anything itself, just returns cached event
420 Self::ValidRepoServed => true,
421 // OwnerRepoStateSent sends its state event and notes purgatory internally
422 Self::OwnerRepoStateSent => true,
326 // All other fixtures return a single event for the caller to send 423 // All other fixtures return a single event for the caller to send
327 _ => false, 424 _ => false,
328 } 425 }
@@ -373,7 +470,7 @@ impl From<AuditMode> for ContextMode {
373/// let ctx = TestContext::new(&client); 470/// let ctx = TestContext::new(&client);
374/// 471///
375/// // Get a repository fixture - will be reused by subsequent TestContexts 472/// // Get a repository fixture - will be reused by subsequent TestContexts
376/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; 473/// let repo = ctx.get_fixture(FixtureKind::ValidRepoSent).await?;
377/// 474///
378/// // For cargo test (isolated fixtures) 475/// // For cargo test (isolated fixtures)
379/// let config = AuditConfig::isolated(); 476/// let config = AuditConfig::isolated();
@@ -381,7 +478,7 @@ impl From<AuditMode> for ContextMode {
381/// let ctx = TestContext::new(&client); 478/// let ctx = TestContext::new(&client);
382/// 479///
383/// // Get a repository fixture - fresh for this TestContext only 480/// // Get a repository fixture - fresh for this TestContext only
384/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; 481/// let repo = ctx.get_fixture(FixtureKind::ValidRepoSent).await?;
385/// # Ok(()) 482/// # Ok(())
386/// # } 483/// # }
387/// ``` 484/// ```
@@ -436,7 +533,7 @@ impl<'a> TestContext<'a> {
436 /// ```no_run 533 /// ```no_run
437 /// # use grasp_audit::*; 534 /// # use grasp_audit::*;
438 /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> { 535 /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
439 /// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; 536 /// let repo = ctx.get_fixture(FixtureKind::ValidRepoSent).await?;
440 /// # Ok(()) 537 /// # Ok(())
441 /// # } 538 /// # }
442 /// ``` 539 /// ```
@@ -517,8 +614,8 @@ impl<'a> TestContext<'a> {
517 /// ```no_run 614 /// ```no_run
518 /// # use grasp_audit::*; 615 /// # use grasp_audit::*;
519 /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> { 616 /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
520 /// // This ensures ValidRepo exists first, then creates MaintainerState 617 /// // This ensures ValidRepoSent exists first, then creates RepoState
521 /// let state = ctx.ensure_fixture(FixtureKind::MaintainerState).await?; 618 /// let state = ctx.ensure_fixture(FixtureKind::RepoState).await?;
522 /// # Ok(()) 619 /// # Ok(())
523 /// # } 620 /// # }
524 /// ``` 621 /// ```
@@ -625,10 +722,10 @@ impl<'a> TestContext<'a> {
625 /// already-cached dependencies. 722 /// already-cached dependencies.
626 async fn build_fixture_inner(&self, kind: FixtureKind) -> Result<Event> { 723 async fn build_fixture_inner(&self, kind: FixtureKind) -> Result<Event> {
627 match kind { 724 match kind {
628 FixtureKind::ValidRepo => { 725 FixtureKind::ValidRepoSent => {
629 // ValidRepo has no dependencies - create a new repo announcement 726 // ValidRepoSent has no dependencies - create a new repo announcement
630 let test_name = format!( 727 let test_name = format!(
631 "fixture-ValidRepo-{}", 728 "fixture-ValidRepoSent-{}",
632 &uuid::Uuid::new_v4().to_string()[..8] 729 &uuid::Uuid::new_v4().to_string()[..8]
633 ); 730 );
634 731
@@ -638,9 +735,15 @@ impl<'a> TestContext<'a> {
638 .with_context(|| format!("create_repo_announcement failed for {}", test_name)) 735 .with_context(|| format!("create_repo_announcement failed for {}", test_name))
639 } 736 }
640 737
738 FixtureKind::ValidRepoServed => {
739 // OwnerStateDataPushed is already ensured as a dependency.
740 // The announcement is now promoted (served). Return the cached ValidRepoSent event.
741 self.get_cached_dependency(FixtureKind::ValidRepoSent)
742 }
743
641 FixtureKind::RepoWithIssue => { 744 FixtureKind::RepoWithIssue => {
642 // ValidRepo is ensured by ensure_fixture before this is called 745 // ValidRepoServed is ensured by ensure_fixture before this is called
643 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 746 let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?;
644 747
645 // Build issue referencing it - caller will send it 748 // Build issue referencing it - caller will send it
646 self.client 749 self.client
@@ -658,8 +761,8 @@ impl<'a> TestContext<'a> {
658 FixtureKind::RepoState => { 761 FixtureKind::RepoState => {
659 use nostr_sdk::prelude::*; 762 use nostr_sdk::prelude::*;
660 763
661 // ValidRepo is ensured by ensure_fixture before this is called 764 // ValidRepoSent is ensured by ensure_fixture before this is called
662 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 765 let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?;
663 766
664 // Extract repo_id from repo announcement 767 // Extract repo_id from repo announcement
665 let repo_id = repo 768 let repo_id = repo
@@ -692,18 +795,52 @@ impl<'a> TestContext<'a> {
692 .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e)) 795 .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))
693 } 796 }
694 797
798 FixtureKind::OwnerRepoStateSent => {
799 use nostr_sdk::prelude::*;
800
801 // ValidRepoSent is ensured by ensure_fixture before this is called
802 let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?;
803 let repo_id = self.extract_repo_id(&repo)?;
804
805 let base_time = Timestamp::now().as_secs();
806 let older_timestamp = Timestamp::from(base_time - 10);
807
808 let state_event = self
809 .client
810 .event_builder(Kind::RepoState, "")
811 .tag(Tag::identifier(&repo_id))
812 .tag(Tag::custom(
813 TagKind::custom("refs/heads/main"),
814 vec![DETERMINISTIC_COMMIT_HASH.to_string()],
815 ))
816 .tag(Tag::custom(
817 TagKind::custom("HEAD"),
818 vec!["ref: refs/heads/main".to_string()],
819 ))
820 .custom_time(older_timestamp)
821 .build(self.client.keys())
822 .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))?;
823
824 // Send to relay - event will be accepted but held in purgatory (no git data yet)
825 self.client
826 .send_event_and_note_purgatory(state_event.clone())
827 .await?;
828
829 Ok(state_event)
830 }
831
695 FixtureKind::PREvent => { 832 FixtureKind::PREvent => {
696 use nostr_sdk::prelude::*; 833 use nostr_sdk::prelude::*;
697 834
698 // ValidRepo is ensured by ensure_fixture before this is called 835 // ValidRepoServed is ensured by ensure_fixture before this is called
699 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 836 let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?;
700 837
701 let repo_id = repo 838 let repo_id = repo
702 .tags 839 .tags
703 .iter() 840 .iter()
704 .find(|t| t.kind() == TagKind::d()) 841 .find(|t| t.kind() == TagKind::d())
705 .and_then(|t| t.content()) 842 .and_then(|t| t.content())
706 .ok_or_else(|| anyhow::anyhow!("Missing repo_id in ValidRepo fixture"))? 843 .ok_or_else(|| anyhow::anyhow!("Missing repo_id in ValidRepoServed fixture"))?
707 .to_string(); 844 .to_string();
708 845
709 // Create PR event 1 second in the past 846 // Create PR event 1 second in the past
@@ -738,15 +875,15 @@ impl<'a> TestContext<'a> {
738 // This fixture is for "Generated" stage only 875 // This fixture is for "Generated" stage only
739 use nostr_sdk::prelude::*; 876 use nostr_sdk::prelude::*;
740 877
741 // ValidRepo is ensured by ensure_fixture before this is called 878 // ValidRepoServed is ensured by ensure_fixture before this is called
742 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 879 let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?;
743 880
744 let repo_id = repo 881 let repo_id = repo
745 .tags 882 .tags
746 .iter() 883 .iter()
747 .find(|t| t.kind() == TagKind::d()) 884 .find(|t| t.kind() == TagKind::d())
748 .and_then(|t| t.content()) 885 .and_then(|t| t.content())
749 .ok_or_else(|| anyhow::anyhow!("Missing repo_id in ValidRepo fixture"))? 886 .ok_or_else(|| anyhow::anyhow!("Missing repo_id in ValidRepoServed fixture"))?
750 .to_string(); 887 .to_string();
751 888
752 // Create PR event 1 second in the past 889 // Create PR event 1 second in the past
@@ -784,6 +921,11 @@ impl<'a> TestContext<'a> {
784 self.build_pr_event_sent_after_wrong_push().await 921 self.build_pr_event_sent_after_wrong_push().await
785 } 922 }
786 923
924 FixtureKind::PREvent2Generated => self.build_pr_event_2_generated().await,
925 FixtureKind::PREvent2Sent => self.build_pr_event_2_sent().await,
926 FixtureKind::PREvent2GitDataPushed => self.build_pr_event_2_git_data_pushed().await,
927 FixtureKind::PREvent2Served => self.build_pr_event_2_served().await,
928
787 FixtureKind::OwnerStateDataPushed => self.build_owner_state_data_pushed().await, 929 FixtureKind::OwnerStateDataPushed => self.build_owner_state_data_pushed().await,
788 930
789 FixtureKind::MaintainerStateDataPushed => { 931 FixtureKind::MaintainerStateDataPushed => {
@@ -858,55 +1000,26 @@ impl<'a> TestContext<'a> {
858 .ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement")) 1000 .ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))
859 } 1001 }
860 1002
861 /// Build OwnerStateDataPushed fixture: full 4-stage fixture for push authorization 1003 /// Build OwnerStateDataPushed fixture: git push + purgatory release for owner's state event
862 /// 1004 ///
863 /// This handles all stages of the fixture: 1005 /// `OwnerRepoStateSent` is ensured as a dependency before this is called — the state event
864 /// 1. **Generated**: Creates RepoState (repo announcement + state event) 1006 /// is already on the relay in purgatory. This fixture completes the cycle:
865 /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message) 1007 /// 1. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay
866 /// 3. **Verify Not Served**: Confirms event is not served by relays 1008 /// 2. **Verified**: Confirms state event is released from purgatory and served
867 /// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay
868 /// 5. **Verified**: Confirms event is served by relay
869 /// 1009 ///
870 /// # Returns 1010 /// # Returns
871 /// The state event (kind 30618) after all stages complete successfully 1011 /// The state event (kind 30618) after git data is pushed and purgatory is released
872 async fn build_owner_state_data_pushed(&self) -> Result<Event> { 1012 async fn build_owner_state_data_pushed(&self) -> Result<Event> {
873 use nostr_sdk::prelude::*; 1013 use nostr_sdk::prelude::*;
874 1014
875 // ============================================================ 1015 // OwnerRepoStateSent is ensured by ensure_fixture before this is called.
876 // Stage 1: ValidRepo is ensured by ensure_fixture before this is called 1016 // The state event is already on the relay in purgatory - retrieve it from cache.
877 // ============================================================ 1017 let state_event = self.get_cached_dependency(FixtureKind::OwnerRepoStateSent)?;
878 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 1018 let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?;
879 let repo_id = self.extract_repo_id(&repo)?; 1019 let repo_id = self.extract_repo_id(&repo)?;
880 1020
881 // Build state event
882 let base_time = Timestamp::now().as_secs();
883 let older_timestamp = Timestamp::from(base_time - 10); // 10 seconds ago
884
885 let state_event = self
886 .client
887 .event_builder(Kind::RepoState, "")
888 .tag(Tag::identifier(&repo_id))
889 .tag(Tag::custom(
890 TagKind::custom("refs/heads/main"),
891 vec![DETERMINISTIC_COMMIT_HASH.to_string()],
892 ))
893 .tag(Tag::custom(
894 TagKind::custom("HEAD"),
895 vec!["ref: refs/heads/main".to_string()],
896 ))
897 .custom_time(older_timestamp)
898 .build(self.client.keys())
899 .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))?;
900
901 // ============================================================
902 // Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served
903 // ============================================================
904 self.client
905 .send_event_expect_purgatory_not_served(state_event.clone())
906 .await?;
907
908 // ============================================================ 1021 // ============================================================
909 // Stage 4: DataPushed - Clone repo, create commit, push 1022 // Stage 1: DataPushed - Clone repo, create commit, push
910 // ============================================================ 1023 // ============================================================
911 1024
912 // Get relay domain from connected relay 1025 // Get relay domain from connected relay
@@ -1008,7 +1121,7 @@ impl<'a> TestContext<'a> {
1008 } 1121 }
1009 1122
1010 // ============================================================ 1123 // ============================================================
1011 // Stage 5: Verify state event is on relay 1124 // Stage 2: Verify state event is released from purgatory
1012 // ============================================================ 1125 // ============================================================
1013 1126
1014 tokio::time::sleep(Duration::from_millis(200)).await; 1127 tokio::time::sleep(Duration::from_millis(200)).await;
@@ -1048,8 +1161,8 @@ impl<'a> TestContext<'a> {
1048 // Extract repo_id from owner's state event (same d-tag structure) 1161 // Extract repo_id from owner's state event (same d-tag structure)
1049 let repo_id = self.extract_repo_id(&owner_state)?; 1162 let repo_id = self.extract_repo_id(&owner_state)?;
1050 1163
1051 // Get the repo (ValidRepo, also cached) for the owner's npub 1164 // Get the repo (ValidRepoSent, also cached) for the owner's npub
1052 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 1165 let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?;
1053 1166
1054 // Build maintainer's state event (state event ONLY - no announcement) 1167 // Build maintainer's state event (state event ONLY - no announcement)
1055 let base_time = Timestamp::now().as_secs(); 1168 let base_time = Timestamp::now().as_secs();
@@ -1074,9 +1187,11 @@ impl<'a> TestContext<'a> {
1074 // ============================================================ 1187 // ============================================================
1075 // Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served 1188 // Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served
1076 // ============================================================ 1189 // ============================================================
1077 self.client 1190 let (_, _in_purgatory) = self
1078 .send_event_expect_purgatory_not_served(maintainer_state_event.clone()) 1191 .client
1192 .send_event_and_note_purgatory(maintainer_state_event.clone())
1079 .await?; 1193 .await?;
1194 // Note: We don't fail if purgatory wasn't observed - the fixture proceeds regardless
1080 1195
1081 // ============================================================ 1196 // ============================================================
1082 // Stage 4: DataPushed - Clone repo, create maintainer commit, push 1197 // Stage 4: DataPushed - Clone repo, create maintainer commit, push
@@ -1194,7 +1309,7 @@ impl<'a> TestContext<'a> {
1194 /// recursive maintainer force-pushes their commit on top. 1309 /// recursive maintainer force-pushes their commit on top.
1195 /// 1310 ///
1196 /// This handles all stages of the fixture: 1311 /// This handles all stages of the fixture:
1197 /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepo + OwnerStateDataPushed) 1312 /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepoSent + OwnerStateDataPushed)
1198 /// Creates MaintainerAnnouncement + RecursiveMaintainerState 1313 /// Creates MaintainerAnnouncement + RecursiveMaintainerState
1199 /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message) 1314 /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message)
1200 /// 3. **Verify Not Served**: Confirms event is not served by relays 1315 /// 3. **Verify Not Served**: Confirms event is not served by relays
@@ -1215,8 +1330,8 @@ impl<'a> TestContext<'a> {
1215 // Extract repo_id from maintainer's state event (same d-tag structure) 1330 // Extract repo_id from maintainer's state event (same d-tag structure)
1216 let repo_id = self.extract_repo_id(&maintainer_state)?; 1331 let repo_id = self.extract_repo_id(&maintainer_state)?;
1217 1332
1218 // Get the repo (ValidRepo, also cached) for the owner's npub 1333 // Get the repo (ValidRepoSent, also cached) for the owner's npub
1219 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 1334 let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?;
1220 1335
1221 // ============================================================ 1336 // ============================================================
1222 // Stage 1 (continued): Generate MaintainerAnnouncement and RecursiveMaintainerState 1337 // Stage 1 (continued): Generate MaintainerAnnouncement and RecursiveMaintainerState
@@ -1249,9 +1364,11 @@ impl<'a> TestContext<'a> {
1249 // ============================================================ 1364 // ============================================================
1250 // Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served 1365 // Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served
1251 // ============================================================ 1366 // ============================================================
1252 self.client 1367 let (_, _in_purgatory) = self
1253 .send_event_expect_purgatory_not_served(recursive_maintainer_state_event.clone()) 1368 .client
1369 .send_event_and_note_purgatory(recursive_maintainer_state_event.clone())
1254 .await?; 1370 .await?;
1371 // Note: We don't fail if purgatory wasn't observed - the fixture proceeds regardless
1255 1372
1256 // ============================================================ 1373 // ============================================================
1257 // Stage 4: DataPushed - Clone repo, create recursive maintainer commit, push 1374 // Stage 4: DataPushed - Clone repo, create recursive maintainer commit, push
@@ -1428,7 +1545,7 @@ impl<'a> TestContext<'a> {
1428 /// 3. A wrong commit is pushed to refs/nostr/<pr-event-id> 1545 /// 3. A wrong commit is pushed to refs/nostr/<pr-event-id>
1429 /// 1546 ///
1430 /// Server state after: 1547 /// Server state after:
1431 /// - ValidRepo announcement on relay 1548 /// - ValidRepoSent announcement on relay
1432 /// - refs/nostr/<pr-event-id> on git server pointing to DETERMINISTIC_COMMIT_HASH (wrong) 1549 /// - refs/nostr/<pr-event-id> on git server pointing to DETERMINISTIC_COMMIT_HASH (wrong)
1433 /// - NO PR event on relay 1550 /// - NO PR event on relay
1434 /// 1551 ///
@@ -1440,8 +1557,8 @@ impl<'a> TestContext<'a> {
1440 let pr_event = self.get_cached_dependency(FixtureKind::PREventGenerated)?; 1557 let pr_event = self.get_cached_dependency(FixtureKind::PREventGenerated)?;
1441 let pr_event_id = pr_event.id.to_hex(); 1558 let pr_event_id = pr_event.id.to_hex();
1442 1559
1443 // Get the ValidRepo to extract repo info 1560 // Get the ValidRepoServed to extract repo info
1444 let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?; 1561 let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?;
1445 let repo_id = self.extract_repo_id(&repo)?; 1562 let repo_id = self.extract_repo_id(&repo)?;
1446 1563
1447 // Get relay domain for cloning 1564 // Get relay domain for cloning
@@ -1462,10 +1579,14 @@ impl<'a> TestContext<'a> {
1462 let _ = fs::remove_dir_all(path); 1579 let _ = fs::remove_dir_all(path);
1463 }; 1580 };
1464 1581
1465 // Create a WRONG commit (Owner variant, not PRTestCommit) 1582 // Create a WRONG commit using a unique file (not PRTestCommit)
1466 // This commit hash will NOT match what's in the PR event's `c` tag 1583 // We use create_commit (non-deterministic) so it always succeeds even if the
1584 // repo already has a commit (e.g. from OwnerStateDataPushed) with the same
1585 // deterministic content. The only requirement is that the hash differs from
1586 // PR_TEST_COMMIT_HASH, which is guaranteed since PR_TEST_COMMIT_HASH is a
1587 // deterministic root-commit with specific content and dates.
1467 let wrong_commit_hash = 1588 let wrong_commit_hash =
1468 match create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner) { 1589 match create_commit(&clone_path, "wrong commit - not the PR test commit") {
1469 Ok(h) => h, 1590 Ok(h) => h,
1470 Err(e) => { 1591 Err(e) => {
1471 cleanup(&clone_path); 1592 cleanup(&clone_path);
@@ -1520,7 +1641,7 @@ impl<'a> TestContext<'a> {
1520 /// 1641 ///
1521 /// This fixture builds on PRWrongCommitPushedBeforeEvent by sending the PR event. 1642 /// This fixture builds on PRWrongCommitPushedBeforeEvent by sending the PR event.
1522 /// After this fixture, the relay has: 1643 /// After this fixture, the relay has:
1523 /// - ValidRepo announcement 1644 /// - ValidRepoServed announcement
1524 /// - PR event 1645 /// - PR event
1525 /// - refs/nostr/<pr-event-id> may have been cleaned up (that's what tests verify) 1646 /// - refs/nostr/<pr-event-id> may have been cleaned up (that's what tests verify)
1526 /// 1647 ///
@@ -1539,6 +1660,173 @@ impl<'a> TestContext<'a> {
1539 Ok(pr_event) 1660 Ok(pr_event)
1540 } 1661 }
1541 1662
1663 /// Build PREvent2Generated fixture
1664 ///
1665 /// Creates a PR event with `c` tag pointing to PR_TEST_COMMIT_HASH_2.
1666 /// The event is NOT sent to the relay.
1667 async fn build_pr_event_2_generated(&self) -> Result<Event> {
1668 use nostr_sdk::prelude::*;
1669
1670 let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?;
1671 let repo_id = self.extract_repo_id(&repo)?;
1672
1673 let base_time = Timestamp::now().as_secs();
1674 let pr_timestamp = Timestamp::from(base_time - 1);
1675
1676 self.client
1677 .event_builder(Kind::GitPullRequest, "Test PR 2 for GRASP validation")
1678 .tag(Tag::custom(
1679 TagKind::custom("a"),
1680 vec![format!(
1681 "30617:{}:{}",
1682 self.client.public_key().to_hex(),
1683 repo_id
1684 )],
1685 ))
1686 .tag(Tag::custom(
1687 TagKind::custom("c"),
1688 vec![PR_TEST_COMMIT_HASH_2.to_string()],
1689 ))
1690 .custom_time(pr_timestamp)
1691 .build(self.client.pr_author_keys())
1692 .map_err(|e| anyhow::anyhow!("Failed to build PR event 2: {}", e))
1693 }
1694
1695 /// Build PREvent2Sent fixture
1696 ///
1697 /// Sends the PR event to relay. Event should enter purgatory.
1698 async fn build_pr_event_2_sent(&self) -> Result<Event> {
1699 let pr_event = self.get_cached_dependency(FixtureKind::PREvent2Generated)?;
1700
1701 let (_, in_purgatory) = self
1702 .client
1703 .send_event_and_note_purgatory(pr_event.clone())
1704 .await?;
1705
1706 if !in_purgatory {
1707 return Err(anyhow::anyhow!(
1708 "PR event 2 was served immediately - purgatory not implemented"
1709 ));
1710 }
1711
1712 Ok(pr_event)
1713 }
1714
1715 /// Build PREvent2GitDataPushed fixture
1716 ///
1717 /// Pushes correct commit to refs/nostr/<pr-event-id> after event was sent.
1718 async fn build_pr_event_2_git_data_pushed(&self) -> Result<Event> {
1719 use nostr_sdk::prelude::*;
1720
1721 let pr_event = self.get_cached_dependency(FixtureKind::PREvent2Sent)?;
1722 let pr_event_id = pr_event.id.to_hex();
1723
1724 let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?;
1725 let repo_id = self.extract_repo_id(&repo)?;
1726
1727 let relay_domain = self.get_relay_domain().await?;
1728
1729 let npub = repo
1730 .pubkey
1731 .to_bech32()
1732 .map_err(|e| anyhow::anyhow!("Failed to convert pubkey: {}", e))?;
1733
1734 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
1735 .map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?;
1736
1737 let cleanup = |path: &PathBuf| {
1738 let _ = fs::remove_dir_all(path);
1739 };
1740
1741 // Reset to orphan state and create deterministic root commit
1742 // Step 1: Create orphan branch (removes all history)
1743 let _ = Command::new("git")
1744 .args(["checkout", "--orphan", "pr-branch"])
1745 .current_dir(&clone_path)
1746 .output();
1747
1748 // Step 2: Clear staged files (orphan keeps files staged from previous branch)
1749 let _ = Command::new("git")
1750 .args(["rm", "-rf", "--cached", "."])
1751 .current_dir(&clone_path)
1752 .output();
1753
1754 // Step 3: Remove all working directory files for clean state (except .git)
1755 for entry in
1756 fs::read_dir(&clone_path).map_err(|e| anyhow::anyhow!("Failed to read dir: {}", e))?
1757 {
1758 if let Ok(entry) = entry {
1759 let path = entry.path();
1760 if path.file_name() != Some(std::ffi::OsStr::new(".git")) {
1761 let _ = fs::remove_file(&path).or_else(|_| fs::remove_dir_all(&path));
1762 }
1763 }
1764 }
1765
1766 let commit_hash = match create_deterministic_commit_with_variant(
1767 &clone_path,
1768 CommitVariant::PRTestCommit2,
1769 ) {
1770 Ok(h) => h,
1771 Err(e) => {
1772 cleanup(&clone_path);
1773 return Err(anyhow::anyhow!("Failed to create PR test commit 2: {}", e));
1774 }
1775 };
1776
1777 if commit_hash != PR_TEST_COMMIT_HASH_2 {
1778 cleanup(&clone_path);
1779 return Err(anyhow::anyhow!(
1780 "PR test commit 2 hash mismatch: got {}, expected {}",
1781 commit_hash,
1782 PR_TEST_COMMIT_HASH_2
1783 ));
1784 }
1785
1786 let push_output = Command::new("git")
1787 .args([
1788 "push",
1789 "origin",
1790 &format!("pr-branch:refs/nostr/{}", pr_event_id),
1791 ])
1792 .current_dir(&clone_path)
1793 .output()
1794 .map_err(|e| {
1795 cleanup(&clone_path);
1796 anyhow::anyhow!("Failed to execute git push: {}", e)
1797 })?;
1798
1799 cleanup(&clone_path);
1800
1801 if !push_output.status.success() {
1802 let stderr = String::from_utf8_lossy(&push_output.stderr);
1803 return Err(anyhow::anyhow!(
1804 "Push to refs/nostr/{} failed: {}",
1805 pr_event_id,
1806 stderr
1807 ));
1808 }
1809
1810 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1811
1812 Ok(pr_event)
1813 }
1814
1815 /// Build PREvent2Served fixture
1816 ///
1817 /// Full fixture: event sent, git pushed, event now served.
1818 async fn build_pr_event_2_served(&self) -> Result<Event> {
1819 let pr_event = self.get_cached_dependency(FixtureKind::PREvent2GitDataPushed)?;
1820
1821 if !self.client.is_event_on_relay(pr_event.id).await? {
1822 return Err(anyhow::anyhow!(
1823 "PR event 2 not released from purgatory after git push"
1824 ));
1825 }
1826
1827 Ok(pr_event)
1828 }
1829
1542 /// Get relay domain (host:port) from the connected relay 1830 /// Get relay domain (host:port) from the connected relay
1543 /// 1831 ///
1544 /// Extracts the domain from the relay URL for git HTTP operations. 1832 /// Extracts the domain from the relay URL for git HTTP operations.
@@ -1845,16 +2133,19 @@ pub enum CommitVariant {
1845 RecursiveMaintainer, 2133 RecursiveMaintainer,
1846 /// PR test commit variant - for PR event tests 2134 /// PR test commit variant - for PR event tests
1847 PRTestCommit, 2135 PRTestCommit,
2136 /// Second PR test commit variant - for second PR event tests
2137 PRTestCommit2,
1848} 2138}
1849 2139
1850impl CommitVariant { 2140impl CommitVariant {
1851 /// Get the file content for this variant 2141 /// Get the file content for this variant
1852 pub fn file_content(&self) -> &'static str { 2142 pub fn file_content(&self) -> &'static str {
1853 match self { 2143 match self {
1854 CommitVariant::Owner => "Initial commit", 2144 CommitVariant::Owner => "Initial commit\n",
1855 CommitVariant::Maintainer => "Maintainer initial commit", 2145 CommitVariant::Maintainer => "Maintainer initial commit\n",
1856 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit", 2146 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit\n",
1857 CommitVariant::PRTestCommit => "PR test deterministic commit", 2147 CommitVariant::PRTestCommit => "PR test deterministic commit\n",
2148 CommitVariant::PRTestCommit2 => "PR test deterministic commit 2\n",
1858 } 2149 }
1859 } 2150 }
1860 2151
@@ -1865,6 +2156,7 @@ impl CommitVariant {
1865 CommitVariant::Maintainer => "Maintainer initial commit", 2156 CommitVariant::Maintainer => "Maintainer initial commit",
1866 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit", 2157 CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit",
1867 CommitVariant::PRTestCommit => "PR test deterministic commit", 2158 CommitVariant::PRTestCommit => "PR test deterministic commit",
2159 CommitVariant::PRTestCommit2 => "PR test deterministic commit 2",
1868 } 2160 }
1869 } 2161 }
1870} 2162}
@@ -2040,10 +2332,10 @@ mod tests {
2040 use std::collections::HashSet; 2332 use std::collections::HashSet;
2041 2333
2042 let mut set = HashSet::new(); 2334 let mut set = HashSet::new();
2043 set.insert(FixtureKind::ValidRepo); 2335 set.insert(FixtureKind::ValidRepoSent);
2044 set.insert(FixtureKind::RepoWithIssue); 2336 set.insert(FixtureKind::RepoWithIssue);
2045 2337
2046 assert!(set.contains(&FixtureKind::ValidRepo)); 2338 assert!(set.contains(&FixtureKind::ValidRepoSent));
2047 assert!(!set.contains(&FixtureKind::RepoWithComment)); 2339 assert!(!set.contains(&FixtureKind::RepoWithComment));
2048 } 2340 }
2049 2341
diff --git a/grasp-audit/src/result.rs b/grasp-audit/src/result.rs
index ae3ef26..0c3ec08 100644
--- a/grasp-audit/src/result.rs
+++ b/grasp-audit/src/result.rs
@@ -1,6 +1,6 @@
1//! Test result types 1//! Test result types
2 2
3use crate::specs::grasp01::{get_sections, GRASP_01_REQUIREMENTS, GRASP_COMMIT_ID}; 3use crate::specs::grasp01::{get_sections, SpecRef, GRASP_01_REQUIREMENTS, GRASP_COMMIT_ID};
4use std::collections::BTreeMap; 4use std::collections::BTreeMap;
5use std::time::{Duration, Instant}; 5use std::time::{Duration, Instant};
6 6
@@ -68,10 +68,16 @@ pub struct TestResult {
68 68
69impl TestResult { 69impl TestResult {
70 /// Create a new test result 70 /// Create a new test result
71 pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { 71 ///
72 /// # Arguments
73 /// * `name` - Test name identifier
74 /// * `spec_ref` - Reference to the spec requirement being tested
75 /// * `requirement` - Human-readable description of what this test validates
76 /// (can be more specific than the general spec text)
77 pub fn new(name: &str, spec_ref: SpecRef, requirement: &str) -> Self {
72 TestResult { 78 TestResult {
73 name: name.to_string(), 79 name: name.to_string(),
74 spec_ref: spec_ref.to_string(), 80 spec_ref: spec_ref.spec_ref_string().to_string(),
75 requirement: requirement.to_string(), 81 requirement: requirement.to_string(),
76 passed: false, 82 passed: false,
77 error: None, 83 error: None,
@@ -293,9 +299,13 @@ mod tests {
293 299
294 #[tokio::test] 300 #[tokio::test]
295 async fn test_result_pass() { 301 async fn test_result_pass() {
296 let result = TestResult::new("test", "SPEC:1", "Must work") 302 let result = TestResult::new(
297 .run(|| async { Ok(()) }) 303 "test",
298 .await; 304 SpecRef::NostrRelayNip01Compliant,
305 "Test requirement",
306 )
307 .run(|| async { Ok(()) })
308 .await;
299 309
300 assert!(result.passed); 310 assert!(result.passed);
301 assert!(result.error.is_none()); 311 assert!(result.error.is_none());
@@ -303,9 +313,13 @@ mod tests {
303 313
304 #[tokio::test] 314 #[tokio::test]
305 async fn test_result_fail() { 315 async fn test_result_fail() {
306 let result = TestResult::new("test", "SPEC:1", "Must work") 316 let result = TestResult::new(
307 .run(|| async { Err("Failed".to_string()) }) 317 "test",
308 .await; 318 SpecRef::NostrRelayNip01Compliant,
319 "Test requirement",
320 )
321 .run(|| async { Err("Failed".to_string()) })
322 .await;
309 323
310 assert!(!result.passed); 324 assert!(!result.passed);
311 assert_eq!(result.error, Some("Failed".to_string())); 325 assert_eq!(result.error, Some("Failed".to_string()));
@@ -315,8 +329,15 @@ mod tests {
315 fn test_audit_result() { 329 fn test_audit_result() {
316 let mut audit = AuditResult::new("Test Spec"); 330 let mut audit = AuditResult::new("Test Spec");
317 331
318 audit.add(TestResult::new("test1", "SPEC:1", "Req1").pass()); 332 audit.add(TestResult::new("test1", SpecRef::NostrRelayNip01Compliant, "Test 1").pass());
319 audit.add(TestResult::new("test2", "SPEC:2", "Req2").fail("Error")); 333 audit.add(
334 TestResult::new(
335 "test2",
336 SpecRef::NostrRelayRejectMissingCloneRelays,
337 "Test 2",
338 )
339 .fail("Error"),
340 );
320 341
321 assert_eq!(audit.total_count(), 2); 342 assert_eq!(audit.total_count(), 2);
322 assert_eq!(audit.passed_count(), 1); 343 assert_eq!(audit.passed_count(), 1);
diff --git a/grasp-audit/src/specs/grasp01/cors.rs b/grasp-audit/src/specs/grasp01/cors.rs
index f8b5f3b..e5d9a27 100644
--- a/grasp-audit/src/specs/grasp01/cors.rs
+++ b/grasp-audit/src/specs/grasp01/cors.rs
@@ -14,6 +14,7 @@
14//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test 14//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
15//! ``` 15//! ```
16 16
17use crate::specs::grasp01::SpecRef;
17use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; 18use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
18use nostr_sdk::prelude::*; 19use nostr_sdk::prelude::*;
19 20
@@ -44,7 +45,7 @@ impl CorsTests {
44 pub async fn test_cors_allow_origin(_client: &AuditClient, relay_domain: &str) -> TestResult { 45 pub async fn test_cors_allow_origin(_client: &AuditClient, relay_domain: &str) -> TestResult {
45 TestResult::new( 46 TestResult::new(
46 "cors_allow_origin", 47 "cors_allow_origin",
47 "GRASP-01:git-http:cors:50", 48 SpecRef::CorsAllowOrigin,
48 "Access-Control-Allow-Origin: * on all responses", 49 "Access-Control-Allow-Origin: * on all responses",
49 ) 50 )
50 .run(|| { 51 .run(|| {
@@ -90,7 +91,7 @@ impl CorsTests {
90 pub async fn test_cors_allow_methods(_client: &AuditClient, relay_domain: &str) -> TestResult { 91 pub async fn test_cors_allow_methods(_client: &AuditClient, relay_domain: &str) -> TestResult {
91 TestResult::new( 92 TestResult::new(
92 "cors_allow_methods", 93 "cors_allow_methods",
93 "GRASP-01:git-http:cors:51", 94 SpecRef::CorsAllowMethods,
94 "Access-Control-Allow-Methods: GET, POST on all responses", 95 "Access-Control-Allow-Methods: GET, POST on all responses",
95 ) 96 )
96 .run(|| { 97 .run(|| {
@@ -134,7 +135,7 @@ impl CorsTests {
134 pub async fn test_cors_allow_headers(_client: &AuditClient, relay_domain: &str) -> TestResult { 135 pub async fn test_cors_allow_headers(_client: &AuditClient, relay_domain: &str) -> TestResult {
135 TestResult::new( 136 TestResult::new(
136 "cors_allow_headers", 137 "cors_allow_headers",
137 "GRASP-01:git-http:cors:52", 138 SpecRef::CorsAllowHeaders,
138 "Access-Control-Allow-Headers: Content-Type on all responses", 139 "Access-Control-Allow-Headers: Content-Type on all responses",
139 ) 140 )
140 .run(|| { 141 .run(|| {
@@ -181,8 +182,8 @@ impl CorsTests {
181 ) -> TestResult { 182 ) -> TestResult {
182 TestResult::new( 183 TestResult::new(
183 "cors_options_preflight", 184 "cors_options_preflight",
184 "GRASP-01:git-http:cors:53", 185 SpecRef::CorsOptionsResponse,
185 "OPTIONS requests return 204 No Content", 186 "OPTIONS requests return 204 No Content with CORS headers",
186 ) 187 )
187 .run(|| { 188 .run(|| {
188 let relay_domain = relay_domain.to_string(); 189 let relay_domain = relay_domain.to_string();
@@ -245,13 +246,13 @@ impl CorsTests {
245 let ctx = TestContext::new(client); 246 let ctx = TestContext::new(client);
246 247
247 // Create repository announcement to get a real repo path 248 // Create repository announcement to get a real repo path
248 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 249 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
249 Ok(r) => r, 250 Ok(r) => r,
250 Err(e) => { 251 Err(e) => {
251 return TestResult::new( 252 return TestResult::new(
252 test_name, 253 test_name,
253 "GRASP-01", 254 SpecRef::CorsAllowOrigin,
254 "CORS headers on real repository endpoint", 255 "CORS headers on real repository endpoints",
255 ) 256 )
256 .fail(format!("Failed to create repo fixture: {}", e)) 257 .fail(format!("Failed to create repo fixture: {}", e))
257 } 258 }
@@ -271,8 +272,8 @@ impl CorsTests {
271 None => { 272 None => {
272 return TestResult::new( 273 return TestResult::new(
273 test_name, 274 test_name,
274 "GRASP-01", 275 SpecRef::CorsAllowOrigin,
275 "CORS headers on real repository endpoint", 276 "CORS headers on real repository endpoints",
276 ) 277 )
277 .fail("Repository announcement missing d tag") 278 .fail("Repository announcement missing d tag")
278 } 279 }
@@ -283,8 +284,8 @@ impl CorsTests {
283 Err(e) => { 284 Err(e) => {
284 return TestResult::new( 285 return TestResult::new(
285 test_name, 286 test_name,
286 "GRASP-01", 287 SpecRef::CorsAllowOrigin,
287 "CORS headers on real repository endpoint", 288 "CORS headers on real repository endpoints",
288 ) 289 )
289 .fail(format!("Failed to convert pubkey to npub: {}", e)) 290 .fail(format!("Failed to convert pubkey to npub: {}", e))
290 } 291 }
@@ -302,8 +303,8 @@ impl CorsTests {
302 Err(e) => { 303 Err(e) => {
303 return TestResult::new( 304 return TestResult::new(
304 test_name, 305 test_name,
305 "GRASP-01", 306 SpecRef::CorsAllowOrigin,
306 "CORS headers on real repository endpoint", 307 "CORS headers on real repository endpoints",
307 ) 308 )
308 .fail(format!("Failed to GET info/refs: {}", e)) 309 .fail(format!("Failed to GET info/refs: {}", e))
309 } 310 }
@@ -313,8 +314,8 @@ impl CorsTests {
313 if let Err(e) = check_cors_allow_origin(&response, "info/refs") { 314 if let Err(e) = check_cors_allow_origin(&response, "info/refs") {
314 return TestResult::new( 315 return TestResult::new(
315 test_name, 316 test_name,
316 "GRASP-01", 317 SpecRef::CorsAllowOrigin,
317 "CORS headers on real repository endpoint", 318 "CORS headers on real repository endpoints",
318 ) 319 )
319 .fail(&e); 320 .fail(&e);
320 } 321 }
@@ -322,8 +323,8 @@ impl CorsTests {
322 if let Err(e) = check_cors_allow_methods(&response, "info/refs") { 323 if let Err(e) = check_cors_allow_methods(&response, "info/refs") {
323 return TestResult::new( 324 return TestResult::new(
324 test_name, 325 test_name,
325 "GRASP-01", 326 SpecRef::CorsAllowMethods,
326 "CORS headers on real repository endpoint", 327 "CORS headers on real repository endpoints",
327 ) 328 )
328 .fail(&e); 329 .fail(&e);
329 } 330 }
@@ -331,16 +332,16 @@ impl CorsTests {
331 if let Err(e) = check_cors_allow_headers(&response, "info/refs") { 332 if let Err(e) = check_cors_allow_headers(&response, "info/refs") {
332 return TestResult::new( 333 return TestResult::new(
333 test_name, 334 test_name,
334 "GRASP-01", 335 SpecRef::CorsAllowHeaders,
335 "CORS headers on real repository endpoint", 336 "CORS headers on real repository endpoints",
336 ) 337 )
337 .fail(&e); 338 .fail(&e);
338 } 339 }
339 340
340 TestResult::new( 341 TestResult::new(
341 test_name, 342 test_name,
342 "GRASP-01", 343 SpecRef::CorsAllowOrigin,
343 "CORS headers on real repository endpoint", 344 "CORS headers on real repository endpoints",
344 ) 345 )
345 .pass() 346 .pass()
346 } 347 }
diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs
index 5b697d8..3375c4d 100644
--- a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs
+++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs
@@ -92,6 +92,7 @@
92//! - Transitive tests verify multi-hop acceptance chains 92//! - Transitive tests verify multi-hop acceptance chains
93 93
94use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected}; 94use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected};
95use crate::specs::grasp01::SpecRef;
95use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; 96use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
96use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp, ToBech32}; 97use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp, ToBech32};
97use std::time::Duration; 98use std::time::Duration;
@@ -148,20 +149,23 @@ impl EventAcceptancePolicyTests {
148 pub async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult { 149 pub async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult {
149 TestResult::new( 150 TestResult::new(
150 "accept_valid_repo_announcement", 151 "accept_valid_repo_announcement",
151 "GRASP-01:nostr-relay:7", 152 SpecRef::NostrRelayNip01Compliant,
152 "Accept valid repository announcements with service in clone and relays tags", 153 "MUST accept repo announcements listing service in clone & relays tags",
153 ) 154 )
154 .run(|| async { 155 .run(|| async {
155 // Create TestContext for mode-aware fixture management 156 // Create TestContext for mode-aware fixture management
156 let ctx = TestContext::new(client); 157 let ctx = TestContext::new(client);
157 158
158 // Request repository fixture - behavior depends on mode 159 // Request repository fixture - behavior depends on mode
159 let event = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 160 let event = ctx
160 format!( 161 .get_fixture(FixtureKind::ValidRepoServed)
161 "Test setup failed: could not get valid repository fixture: {}", 162 .await
162 e 163 .map_err(|e| {
163 ) 164 format!(
164 })?; 165 "Test setup failed: could not get valid repository fixture: {}",
166 e
167 )
168 })?;
165 169
166 // Get relay URL for validation 170 // Get relay URL for validation
167 let relay_url = client 171 let relay_url = client
@@ -253,8 +257,8 @@ impl EventAcceptancePolicyTests {
253 ) -> TestResult { 257 ) -> TestResult {
254 TestResult::new( 258 TestResult::new(
255 "reject_repo_announcement_missing_clone_tag", 259 "reject_repo_announcement_missing_clone_tag",
256 "GRASP-01:nostr-relay:9", 260 SpecRef::NostrRelayRejectMissingCloneRelays,
257 "Reject repository announcements without service in clone tag", 261 "MUST reject announcements not listing service in clone tag",
258 ) 262 )
259 .run(|| async { 263 .run(|| async {
260 // Get relay URL from client 264 // Get relay URL from client
@@ -329,8 +333,8 @@ impl EventAcceptancePolicyTests {
329 ) -> TestResult { 333 ) -> TestResult {
330 TestResult::new( 334 TestResult::new(
331 "reject_repo_announcement_missing_relays_tag", 335 "reject_repo_announcement_missing_relays_tag",
332 "GRASP-01:nostr-relay:9", 336 SpecRef::NostrRelayRejectMissingCloneRelays,
333 "Reject repository announcements without service in relays tag", 337 "MUST reject announcements not listing service in relays tag",
334 ) 338 )
335 .run(|| async { 339 .run(|| async {
336 // Get relay URL from client 340 // Get relay URL from client
@@ -425,8 +429,8 @@ impl EventAcceptancePolicyTests {
425 ) -> TestResult { 429 ) -> TestResult {
426 TestResult::new( 430 TestResult::new(
427 "accept_recursive_maintainer_announcement_without_service", 431 "accept_recursive_maintainer_announcement_without_service",
428 "GRASP-01:nostr-relay:9", 432 SpecRef::NostrRelayRejectMissingCloneRelays,
429 "Accept recursive maintainer announcement for chain discovery (even without GRASP server in clone)", 433 "MUST accept recursive maintainer announcements for chain discovery",
430 ) 434 )
431 .run(|| async { 435 .run(|| async {
432 // Create TestContext for mode-aware fixture management 436 // Create TestContext for mode-aware fixture management
@@ -593,7 +597,7 @@ impl EventAcceptancePolicyTests {
593 pub async fn test_accept_issue_via_a_tag(client: &AuditClient) -> TestResult { 597 pub async fn test_accept_issue_via_a_tag(client: &AuditClient) -> TestResult {
594 TestResult::new( 598 TestResult::new(
595 "accept_issue_via_a_tag", 599 "accept_issue_via_a_tag",
596 "GRASP-01:nostr-relay:13", 600 SpecRef::NostrRelayMustAcceptTaggedEvents,
597 "Accept issue referencing repo via 'a' tag", 601 "Accept issue referencing repo via 'a' tag",
598 ) 602 )
599 .run(|| async { 603 .run(|| async {
@@ -601,12 +605,15 @@ impl EventAcceptancePolicyTests {
601 let ctx = TestContext::new(client); 605 let ctx = TestContext::new(client);
602 606
603 // NEW: Get repository fixture (mode-aware) 607 // NEW: Get repository fixture (mode-aware)
604 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 608 let repo = ctx
605 format!( 609 .get_fixture(FixtureKind::ValidRepoServed)
606 "Test setup failed: could not get valid repository fixture: {}", 610 .await
607 e 611 .map_err(|e| {
608 ) 612 format!(
609 })?; 613 "Test setup failed: could not get valid repository fixture: {}",
614 e
615 )
616 })?;
610 617
611 // 2. Create issue that references the repo 618 // 2. Create issue that references the repo
612 let issue = Self::create_issue_for_repo(client, &repo, "Test Issue 1")?; 619 let issue = Self::create_issue_for_repo(client, &repo, "Test Issue 1")?;
@@ -628,7 +635,7 @@ impl EventAcceptancePolicyTests {
628 pub async fn test_accept_comment_via_capital_a_tag(client: &AuditClient) -> TestResult { 635 pub async fn test_accept_comment_via_capital_a_tag(client: &AuditClient) -> TestResult {
629 TestResult::new( 636 TestResult::new(
630 "accept_comment_via_A_tag", 637 "accept_comment_via_A_tag",
631 "GRASP-01:nostr-relay:13", 638 SpecRef::NostrRelayMustAcceptTaggedEvents,
632 "Accept NIP-22 comment with root 'A' tag referencing repo", 639 "Accept NIP-22 comment with root 'A' tag referencing repo",
633 ) 640 )
634 .run(|| async { 641 .run(|| async {
@@ -636,12 +643,15 @@ impl EventAcceptancePolicyTests {
636 let ctx = TestContext::new(client); 643 let ctx = TestContext::new(client);
637 644
638 // Get repository fixture (mode-aware) 645 // Get repository fixture (mode-aware)
639 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 646 let repo = ctx
640 format!( 647 .get_fixture(FixtureKind::ValidRepoServed)
641 "Test setup failed: could not get valid repository fixture: {}", 648 .await
642 e 649 .map_err(|e| {
643 ) 650 format!(
644 })?; 651 "Test setup failed: could not get valid repository fixture: {}",
652 e
653 )
654 })?;
645 655
646 // Extract repo_id and create `A` tag manually 656 // Extract repo_id and create `A` tag manually
647 let repo_id = 657 let repo_id =
@@ -681,20 +691,23 @@ impl EventAcceptancePolicyTests {
681 pub async fn test_accept_kind1_via_q_tag(client: &AuditClient) -> TestResult { 691 pub async fn test_accept_kind1_via_q_tag(client: &AuditClient) -> TestResult {
682 TestResult::new( 692 TestResult::new(
683 "accept_kind1_via_q_tag", 693 "accept_kind1_via_q_tag",
684 "GRASP-01:nostr-relay:13", 694 SpecRef::NostrRelayMustAcceptTaggedEvents,
685 "Accept kind 1 note quoting repo via 'q' tag", 695 "Accept kind 1 text note quoting repo via 'q' tag",
686 ) 696 )
687 .run(|| async { 697 .run(|| async {
688 // Create TestContext 698 // Create TestContext
689 let ctx = TestContext::new(client); 699 let ctx = TestContext::new(client);
690 700
691 // Get repository fixture (mode-aware) 701 // Get repository fixture (mode-aware)
692 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 702 let repo = ctx
693 format!( 703 .get_fixture(FixtureKind::ValidRepoServed)
694 "Test setup failed: could not get valid repository fixture: {}", 704 .await
695 e 705 .map_err(|e| {
696 ) 706 format!(
697 })?; 707 "Test setup failed: could not get valid repository fixture: {}",
708 e
709 )
710 })?;
698 711
699 // Extract repo_id and create `q` tag 712 // Extract repo_id and create `q` tag
700 let repo_id = 713 let repo_id =
@@ -731,8 +744,8 @@ impl EventAcceptancePolicyTests {
731 pub async fn test_accept_issue_quoting_issue_via_q(client: &AuditClient) -> TestResult { 744 pub async fn test_accept_issue_quoting_issue_via_q(client: &AuditClient) -> TestResult {
732 TestResult::new( 745 TestResult::new(
733 "accept_issue_quoting_issue_via_q", 746 "accept_issue_quoting_issue_via_q",
734 "GRASP-01:nostr-relay:13", 747 SpecRef::NostrRelayMustAcceptTaggedEvents,
735 "Accept issue quoting accepted issue (transitive)", 748 "Accept issue quoting another accepted issue (transitive)",
736 ) 749 )
737 .run(|| async { 750 .run(|| async {
738 // Create TestContext 751 // Create TestContext
@@ -777,7 +790,7 @@ impl EventAcceptancePolicyTests {
777 pub async fn test_accept_comment_via_capital_e_tag(client: &AuditClient) -> TestResult { 790 pub async fn test_accept_comment_via_capital_e_tag(client: &AuditClient) -> TestResult {
778 TestResult::new( 791 TestResult::new(
779 "accept_comment_via_E_tag", 792 "accept_comment_via_E_tag",
780 "GRASP-01:nostr-relay:13", 793 SpecRef::NostrRelayMustAcceptTaggedEvents,
781 "Accept NIP-22 comment with root 'E' tag to accepted issue", 794 "Accept NIP-22 comment with root 'E' tag to accepted issue",
782 ) 795 )
783 .run(|| async { 796 .run(|| async {
@@ -816,7 +829,7 @@ impl EventAcceptancePolicyTests {
816 pub async fn test_accept_kind1_via_e_tag(client: &AuditClient) -> TestResult { 829 pub async fn test_accept_kind1_via_e_tag(client: &AuditClient) -> TestResult {
817 TestResult::new( 830 TestResult::new(
818 "accept_kind1_via_e_tag", 831 "accept_kind1_via_e_tag",
819 "GRASP-01:nostr-relay:13", 832 SpecRef::NostrRelayMustAcceptTaggedEvents,
820 "Accept kind 1 reply via 'e' tag to accepted kind 1", 833 "Accept kind 1 reply via 'e' tag to accepted kind 1",
821 ) 834 )
822 .run(|| async { 835 .run(|| async {
@@ -824,12 +837,15 @@ impl EventAcceptancePolicyTests {
824 let ctx = TestContext::new(client); 837 let ctx = TestContext::new(client);
825 838
826 // Get repository fixture (mode-aware) 839 // Get repository fixture (mode-aware)
827 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 840 let repo = ctx
828 format!( 841 .get_fixture(FixtureKind::ValidRepoServed)
829 "Test setup failed: could not get valid repository fixture: {}", 842 .await
830 e 843 .map_err(|e| {
831 ) 844 format!(
832 })?; 845 "Test setup failed: could not get valid repository fixture: {}",
846 e
847 )
848 })?;
833 849
834 // Create Kind 1 A that quotes the repo (makes it accepted) 850 // Create Kind 1 A that quotes the repo (makes it accepted)
835 let repo_id = Self::extract_d_tag(&repo).ok_or("Failed to extract repo_id")?; 851 let repo_id = Self::extract_d_tag(&repo).ok_or("Failed to extract repo_id")?;
@@ -872,7 +888,7 @@ impl EventAcceptancePolicyTests {
872 pub async fn test_accept_kind1_referenced_in_issue(client: &AuditClient) -> TestResult { 888 pub async fn test_accept_kind1_referenced_in_issue(client: &AuditClient) -> TestResult {
873 TestResult::new( 889 TestResult::new(
874 "accept_kind1_referenced_in_issue", 890 "accept_kind1_referenced_in_issue",
875 "GRASP-01:nostr-relay:13", 891 SpecRef::NostrRelayMustAcceptTaggedEvents,
876 "Accept kind 1 referenced in accepted issue (forward ref)", 892 "Accept kind 1 referenced in accepted issue (forward ref)",
877 ) 893 )
878 .run(|| async { 894 .run(|| async {
@@ -880,12 +896,15 @@ impl EventAcceptancePolicyTests {
880 let ctx = TestContext::new(client); 896 let ctx = TestContext::new(client);
881 897
882 // Get repository fixture (mode-aware) 898 // Get repository fixture (mode-aware)
883 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 899 let repo = ctx
884 format!( 900 .get_fixture(FixtureKind::ValidRepoServed)
885 "Test setup failed: could not get valid repository fixture: {}", 901 .await
886 e 902 .map_err(|e| {
887 ) 903 format!(
888 })?; 904 "Test setup failed: could not get valid repository fixture: {}",
905 e
906 )
907 })?;
889 908
890 // Verify repo is queryable (ensures it's fully indexed before we reference it) 909 // Verify repo is queryable (ensures it's fully indexed before we reference it)
891 let repo_id = Self::extract_d_tag(&repo).ok_or("Failed to extract repo_id")?; 910 let repo_id = Self::extract_d_tag(&repo).ok_or("Failed to extract repo_id")?;
@@ -964,7 +983,7 @@ impl EventAcceptancePolicyTests {
964 pub async fn test_accept_comment_referenced_in_comment(client: &AuditClient) -> TestResult { 983 pub async fn test_accept_comment_referenced_in_comment(client: &AuditClient) -> TestResult {
965 TestResult::new( 984 TestResult::new(
966 "accept_comment_referenced_in_comment", 985 "accept_comment_referenced_in_comment",
967 "GRASP-01:nostr-relay:13", 986 SpecRef::NostrRelayMustAcceptTaggedEvents,
968 "Accept comment referenced in another accepted comment (forward ref)", 987 "Accept comment referenced in another accepted comment (forward ref)",
969 ) 988 )
970 .run(|| async { 989 .run(|| async {
@@ -1025,7 +1044,7 @@ impl EventAcceptancePolicyTests {
1025 pub async fn test_accept_kind1_referenced_in_kind1(client: &AuditClient) -> TestResult { 1044 pub async fn test_accept_kind1_referenced_in_kind1(client: &AuditClient) -> TestResult {
1026 TestResult::new( 1045 TestResult::new(
1027 "accept_kind1_referenced_in_kind1", 1046 "accept_kind1_referenced_in_kind1",
1028 "GRASP-01:nostr-relay:13", 1047 SpecRef::NostrRelayMustAcceptTaggedEvents,
1029 "Accept kind 1 referenced in another accepted kind 1 (forward ref)", 1048 "Accept kind 1 referenced in another accepted kind 1 (forward ref)",
1030 ) 1049 )
1031 .run(|| async { 1050 .run(|| async {
@@ -1033,12 +1052,15 @@ impl EventAcceptancePolicyTests {
1033 let ctx = TestContext::new(client); 1052 let ctx = TestContext::new(client);
1034 1053
1035 // Get repository fixture (mode-aware) 1054 // Get repository fixture (mode-aware)
1036 let repo = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 1055 let repo = ctx
1037 format!( 1056 .get_fixture(FixtureKind::ValidRepoServed)
1038 "Test setup failed: could not get valid repository fixture: {}", 1057 .await
1039 e 1058 .map_err(|e| {
1040 ) 1059 format!(
1041 })?; 1060 "Test setup failed: could not get valid repository fixture: {}",
1061 e
1062 )
1063 })?;
1042 1064
1043 // Create Kind 1 A locally but DON'T send it yet 1065 // Create Kind 1 A locally but DON'T send it yet
1044 let kind1_a = client 1066 let kind1_a = client
@@ -1083,7 +1105,7 @@ impl EventAcceptancePolicyTests {
1083 pub async fn test_reject_orphan_issue(client: &AuditClient) -> TestResult { 1105 pub async fn test_reject_orphan_issue(client: &AuditClient) -> TestResult {
1084 TestResult::new( 1106 TestResult::new(
1085 "reject_orphan_issue", 1107 "reject_orphan_issue",
1086 "GRASP-01:nostr-relay:18", 1108 SpecRef::NostrRelayMayRejectSpamCuration,
1087 "Reject issue referencing unaccepted repo", 1109 "Reject issue referencing unaccepted repo",
1088 ) 1110 )
1089 .run(|| async { 1111 .run(|| async {
@@ -1110,7 +1132,7 @@ impl EventAcceptancePolicyTests {
1110 pub async fn test_reject_orphan_kind1(client: &AuditClient) -> TestResult { 1132 pub async fn test_reject_orphan_kind1(client: &AuditClient) -> TestResult {
1111 TestResult::new( 1133 TestResult::new(
1112 "reject_orphan_kind1", 1134 "reject_orphan_kind1",
1113 "GRASP-01:nostr-relay:18", 1135 SpecRef::NostrRelayMayRejectSpamCuration,
1114 "Reject kind 1 with no repo references", 1136 "Reject kind 1 with no repo references",
1115 ) 1137 )
1116 .run(|| async { 1138 .run(|| async {
@@ -1139,7 +1161,7 @@ impl EventAcceptancePolicyTests {
1139 pub async fn test_reject_comment_quoting_other_repo(client: &AuditClient) -> TestResult { 1161 pub async fn test_reject_comment_quoting_other_repo(client: &AuditClient) -> TestResult {
1140 TestResult::new( 1162 TestResult::new(
1141 "reject_comment_quoting_other_repo", 1163 "reject_comment_quoting_other_repo",
1142 "GRASP-01:nostr-relay:18", 1164 SpecRef::NostrRelayMayRejectSpamCuration,
1143 "Reject comment quoting unaccepted repo", 1165 "Reject comment quoting unaccepted repo",
1144 ) 1166 )
1145 .run(|| async { 1167 .run(|| async {
@@ -1147,12 +1169,15 @@ impl EventAcceptancePolicyTests {
1147 let ctx = TestContext::new(client); 1169 let ctx = TestContext::new(client);
1148 1170
1149 // Get accepted repo A fixture (mode-aware) 1171 // Get accepted repo A fixture (mode-aware)
1150 let _repo_a = ctx.get_fixture(FixtureKind::ValidRepo).await.map_err(|e| { 1172 let _repo_a = ctx
1151 format!( 1173 .get_fixture(FixtureKind::ValidRepoServed)
1152 "Test setup failed: could not get valid repository fixture: {}", 1174 .await
1153 e 1175 .map_err(|e| {
1154 ) 1176 format!(
1155 })?; 1177 "Test setup failed: could not get valid repository fixture: {}",
1178 e
1179 )
1180 })?;
1156 1181
1157 // Create Repo B but DON'T send it (unaccepted) 1182 // Create Repo B but DON'T send it (unaccepted)
1158 let repo_b = Self::create_test_repo(client, "unaccepted-repo-b").await?; 1183 let repo_b = Self::create_test_repo(client, "unaccepted-repo-b").await?;
diff --git a/grasp-audit/src/specs/grasp01/git_clone.rs b/grasp-audit/src/specs/grasp01/git_clone.rs
index e162558..0c223f4 100644
--- a/grasp-audit/src/specs/grasp01/git_clone.rs
+++ b/grasp-audit/src/specs/grasp01/git_clone.rs
@@ -15,6 +15,7 @@
15//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test 15//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
16//! ``` 16//! ```
17 17
18use crate::specs::grasp01::SpecRef;
18use crate::{AuditClient, FixtureKind, TestContext, TestResult}; 19use crate::{AuditClient, FixtureKind, TestContext, TestResult};
19use nostr_sdk::prelude::*; 20use nostr_sdk::prelude::*;
20use std::fs; 21use std::fs;
@@ -48,12 +49,12 @@ impl GitCloneTests {
48 let ctx = TestContext::new(client); 49 let ctx = TestContext::new(client);
49 50
50 // Create repository announcement 51 // Create repository announcement
51 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 52 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
52 Ok(r) => r, 53 Ok(r) => r,
53 Err(e) => { 54 Err(e) => {
54 return TestResult::new( 55 return TestResult::new(
55 test_name, 56 test_name,
56 "GRASP-01:git-http:34", 57 SpecRef::GitServeRepository,
57 "Repository must be cloneable via Git HTTP backend", 58 "Repository must be cloneable via Git HTTP backend",
58 ) 59 )
59 .fail(format!("Failed to create repo fixture: {}", e)) 60 .fail(format!("Failed to create repo fixture: {}", e))
@@ -74,7 +75,7 @@ impl GitCloneTests {
74 None => { 75 None => {
75 return TestResult::new( 76 return TestResult::new(
76 test_name, 77 test_name,
77 "GRASP-01", 78 SpecRef::GitServeRepository,
78 "Repository must be cloneable via Git HTTP backend", 79 "Repository must be cloneable via Git HTTP backend",
79 ) 80 )
80 .fail("Repository announcement missing d tag") 81 .fail("Repository announcement missing d tag")
@@ -86,7 +87,7 @@ impl GitCloneTests {
86 Err(e) => { 87 Err(e) => {
87 return TestResult::new( 88 return TestResult::new(
88 test_name, 89 test_name,
89 "GRASP-01:git-http:34", 90 SpecRef::GitServeRepository,
90 "Repository must be cloneable via Git HTTP backend", 91 "Repository must be cloneable via Git HTTP backend",
91 ) 92 )
92 .fail(format!("Failed to convert pubkey to npub: {}", e)) 93 .fail(format!("Failed to convert pubkey to npub: {}", e))
@@ -121,7 +122,7 @@ impl GitCloneTests {
121 cleanup(); 122 cleanup();
122 return TestResult::new( 123 return TestResult::new(
123 test_name, 124 test_name,
124 "GRASP-01:git-http:34", 125 SpecRef::GitServeRepository,
125 "Repository must be cloneable via Git HTTP backend", 126 "Repository must be cloneable via Git HTTP backend",
126 ) 127 )
127 .fail(format!("Failed to execute git clone: {}", e)); 128 .fail(format!("Failed to execute git clone: {}", e));
@@ -133,7 +134,7 @@ impl GitCloneTests {
133 let stderr = String::from_utf8_lossy(&output.stderr); 134 let stderr = String::from_utf8_lossy(&output.stderr);
134 return TestResult::new( 135 return TestResult::new(
135 test_name, 136 test_name,
136 "GRASP-01:git-http:34", 137 SpecRef::GitServeRepository,
137 "Repository must be cloneable via Git HTTP backend", 138 "Repository must be cloneable via Git HTTP backend",
138 ) 139 )
139 .fail(format!("Git clone failed: {}", stderr)); 140 .fail(format!("Git clone failed: {}", stderr));
@@ -144,7 +145,7 @@ impl GitCloneTests {
144 cleanup(); 145 cleanup();
145 return TestResult::new( 146 return TestResult::new(
146 test_name, 147 test_name,
147 "GRASP-01:git-http:34", 148 SpecRef::GitServeRepository,
148 "Repository must be cloneable via Git HTTP backend", 149 "Repository must be cloneable via Git HTTP backend",
149 ) 150 )
150 .fail("Cloned repository missing .git directory"); 151 .fail("Cloned repository missing .git directory");
@@ -153,7 +154,7 @@ impl GitCloneTests {
153 cleanup(); 154 cleanup();
154 TestResult::new( 155 TestResult::new(
155 test_name, 156 test_name,
156 "GRASP-01:git-http:34", 157 SpecRef::GitServeRepository,
157 "Repository must be cloneable via Git HTTP backend", 158 "Repository must be cloneable via Git HTTP backend",
158 ) 159 )
159 .pass() 160 .pass()
@@ -170,12 +171,12 @@ impl GitCloneTests {
170 let ctx = TestContext::new(client); 171 let ctx = TestContext::new(client);
171 172
172 // Create repository announcement 173 // Create repository announcement
173 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 174 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
174 Ok(r) => r, 175 Ok(r) => r,
175 Err(e) => { 176 Err(e) => {
176 return TestResult::new( 177 return TestResult::new(
177 test_name, 178 test_name,
178 "GRASP-01:git-http:34", 179 SpecRef::GitServeRepository,
179 "Clone URL must follow correct format", 180 "Clone URL must follow correct format",
180 ) 181 )
181 .fail(format!("Failed to create repo fixture: {}", e)) 182 .fail(format!("Failed to create repo fixture: {}", e))
@@ -203,7 +204,7 @@ impl GitCloneTests {
203 if !valid_url.contains(&npub) { 204 if !valid_url.contains(&npub) {
204 return TestResult::new( 205 return TestResult::new(
205 test_name, 206 test_name,
206 "GRASP-01:git-http:34", 207 SpecRef::GitServeRepository,
207 "Clone URL must follow correct format", 208 "Clone URL must follow correct format",
208 ) 209 )
209 .fail("URL missing npub"); 210 .fail("URL missing npub");
@@ -212,7 +213,7 @@ impl GitCloneTests {
212 if !valid_url.contains(&format!("{}.git", repo_id)) { 213 if !valid_url.contains(&format!("{}.git", repo_id)) {
213 return TestResult::new( 214 return TestResult::new(
214 test_name, 215 test_name,
215 "GRASP-01:git-http:34", 216 SpecRef::GitServeRepository,
216 "Clone URL must follow correct format", 217 "Clone URL must follow correct format",
217 ) 218 )
218 .fail("URL missing repository identifier"); 219 .fail("URL missing repository identifier");
@@ -241,7 +242,7 @@ impl GitCloneTests {
241 if output.status.success() { 242 if output.status.success() {
242 return TestResult::new( 243 return TestResult::new(
243 test_name, 244 test_name,
244 "GRASP-01:git-http:34", 245 SpecRef::GitServeRepository,
245 "Clone URL must follow correct format", 246 "Clone URL must follow correct format",
246 ) 247 )
247 .fail("Invalid URL was accepted (should have been rejected)"); 248 .fail("Invalid URL was accepted (should have been rejected)");
@@ -249,7 +250,7 @@ impl GitCloneTests {
249 250
250 TestResult::new( 251 TestResult::new(
251 test_name, 252 test_name,
252 "GRASP-01:git-http:34", 253 SpecRef::GitServeRepository,
253 "Clone URL must follow correct format", 254 "Clone URL must follow correct format",
254 ) 255 )
255 .pass() 256 .pass()
@@ -273,12 +274,12 @@ impl GitCloneTests {
273 let ctx = TestContext::new(client); 274 let ctx = TestContext::new(client);
274 275
275 // Create repository announcement 276 // Create repository announcement
276 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 277 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
277 Ok(r) => r, 278 Ok(r) => r,
278 Err(e) => { 279 Err(e) => {
279 return TestResult::new( 280 return TestResult::new(
280 test_name, 281 test_name,
281 "GRASP-01:git-http:42", 282 SpecRef::GitIncludeAllowSha1InWant,
282 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 283 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
283 ) 284 )
284 .fail(format!("Failed to create repo fixture: {}", e)) 285 .fail(format!("Failed to create repo fixture: {}", e))
@@ -299,7 +300,7 @@ impl GitCloneTests {
299 None => { 300 None => {
300 return TestResult::new( 301 return TestResult::new(
301 test_name, 302 test_name,
302 "GRASP-01:git-http:42", 303 SpecRef::GitIncludeAllowSha1InWant,
303 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 304 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
304 ) 305 )
305 .fail("Repository announcement missing d tag") 306 .fail("Repository announcement missing d tag")
@@ -311,7 +312,7 @@ impl GitCloneTests {
311 Err(e) => { 312 Err(e) => {
312 return TestResult::new( 313 return TestResult::new(
313 test_name, 314 test_name,
314 "GRASP-01:git-http:42", 315 SpecRef::GitIncludeAllowSha1InWant,
315 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 316 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
316 ) 317 )
317 .fail(format!("Failed to convert pubkey to npub: {}", e)) 318 .fail(format!("Failed to convert pubkey to npub: {}", e))
@@ -331,7 +332,7 @@ impl GitCloneTests {
331 Err(e) => { 332 Err(e) => {
332 return TestResult::new( 333 return TestResult::new(
333 test_name, 334 test_name,
334 "GRASP-01:git-http:42", 335 SpecRef::GitIncludeAllowSha1InWant,
335 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 336 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
336 ) 337 )
337 .fail(format!("HTTP request failed: {}", e)) 338 .fail(format!("HTTP request failed: {}", e))
@@ -341,7 +342,7 @@ impl GitCloneTests {
341 if !response.status().is_success() { 342 if !response.status().is_success() {
342 return TestResult::new( 343 return TestResult::new(
343 test_name, 344 test_name,
344 "GRASP-01:git-http:42", 345 SpecRef::GitIncludeAllowSha1InWant,
345 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 346 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
346 ) 347 )
347 .fail(format!( 348 .fail(format!(
@@ -356,7 +357,7 @@ impl GitCloneTests {
356 Err(e) => { 357 Err(e) => {
357 return TestResult::new( 358 return TestResult::new(
358 test_name, 359 test_name,
359 "GRASP-01:git-http:42", 360 SpecRef::GitIncludeAllowSha1InWant,
360 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 361 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
361 ) 362 )
362 .fail(format!("Failed to read response body: {}", e)) 363 .fail(format!("Failed to read response body: {}", e))
@@ -370,7 +371,7 @@ impl GitCloneTests {
370 if !has_allow_reachable { 371 if !has_allow_reachable {
371 return TestResult::new( 372 return TestResult::new(
372 test_name, 373 test_name,
373 "GRASP-01:git-http:42", 374 SpecRef::GitIncludeAllowSha1InWant,
374 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 375 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
375 ) 376 )
376 .fail("Missing capability: allow-reachable-sha1-in-want"); 377 .fail("Missing capability: allow-reachable-sha1-in-want");
@@ -379,7 +380,7 @@ impl GitCloneTests {
379 if !has_allow_tip { 380 if !has_allow_tip {
380 return TestResult::new( 381 return TestResult::new(
381 test_name, 382 test_name,
382 "GRASP-01:git-http:42", 383 SpecRef::GitIncludeAllowSha1InWant,
383 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 384 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
384 ) 385 )
385 .fail("Missing capability: allow-tip-sha1-in-want"); 386 .fail("Missing capability: allow-tip-sha1-in-want");
@@ -387,7 +388,7 @@ impl GitCloneTests {
387 388
388 TestResult::new( 389 TestResult::new(
389 test_name, 390 test_name,
390 "GRASP-01:git-http:42", 391 SpecRef::GitIncludeAllowSha1InWant,
391 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", 392 "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement",
392 ) 393 )
393 .pass() 394 .pass()
diff --git a/grasp-audit/src/specs/grasp01/git_filter.rs b/grasp-audit/src/specs/grasp01/git_filter.rs
index 21bab0a..31d86aa 100644
--- a/grasp-audit/src/specs/grasp01/git_filter.rs
+++ b/grasp-audit/src/specs/grasp01/git_filter.rs
@@ -22,6 +22,7 @@
22//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test 22//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
23//! ``` 23//! ```
24 24
25use crate::specs::grasp01::SpecRef;
25use crate::{AuditClient, FixtureKind, TestContext, TestResult}; 26use crate::{AuditClient, FixtureKind, TestContext, TestResult};
26use nostr_sdk::prelude::*; 27use nostr_sdk::prelude::*;
27use std::fs; 28use std::fs;
@@ -61,12 +62,12 @@ impl GitFilterTests {
61 let ctx = TestContext::new(client); 62 let ctx = TestContext::new(client);
62 63
63 // Create repository announcement 64 // Create repository announcement
64 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 65 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
65 Ok(r) => r, 66 Ok(r) => r,
66 Err(e) => { 67 Err(e) => {
67 return TestResult::new( 68 return TestResult::new(
68 test_name, 69 test_name,
69 "GRASP-01:git-http:42", 70 SpecRef::GitIncludeAllowSha1InWant,
70 "MUST include uploadpack.allowFilter in advertisement", 71 "MUST include uploadpack.allowFilter in advertisement",
71 ) 72 )
72 .fail(format!("Failed to create repo fixture: {}", e)) 73 .fail(format!("Failed to create repo fixture: {}", e))
@@ -87,7 +88,7 @@ impl GitFilterTests {
87 None => { 88 None => {
88 return TestResult::new( 89 return TestResult::new(
89 test_name, 90 test_name,
90 "GRASP-01:git-http:42", 91 SpecRef::GitIncludeAllowSha1InWant,
91 "MUST include uploadpack.allowFilter in advertisement", 92 "MUST include uploadpack.allowFilter in advertisement",
92 ) 93 )
93 .fail("Repository announcement missing d tag") 94 .fail("Repository announcement missing d tag")
@@ -99,7 +100,7 @@ impl GitFilterTests {
99 Err(e) => { 100 Err(e) => {
100 return TestResult::new( 101 return TestResult::new(
101 test_name, 102 test_name,
102 "GRASP-01:git-http:42", 103 SpecRef::GitIncludeAllowSha1InWant,
103 "MUST include uploadpack.allowFilter in advertisement", 104 "MUST include uploadpack.allowFilter in advertisement",
104 ) 105 )
105 .fail(format!("Failed to convert pubkey to npub: {}", e)) 106 .fail(format!("Failed to convert pubkey to npub: {}", e))
@@ -119,7 +120,7 @@ impl GitFilterTests {
119 Err(e) => { 120 Err(e) => {
120 return TestResult::new( 121 return TestResult::new(
121 test_name, 122 test_name,
122 "GRASP-01:git-http:42", 123 SpecRef::GitIncludeAllowSha1InWant,
123 "MUST include uploadpack.allowFilter in advertisement", 124 "MUST include uploadpack.allowFilter in advertisement",
124 ) 125 )
125 .fail(format!("HTTP request failed: {}", e)) 126 .fail(format!("HTTP request failed: {}", e))
@@ -129,7 +130,7 @@ impl GitFilterTests {
129 if !response.status().is_success() { 130 if !response.status().is_success() {
130 return TestResult::new( 131 return TestResult::new(
131 test_name, 132 test_name,
132 "GRASP-01:git-http:42", 133 SpecRef::GitIncludeAllowSha1InWant,
133 "MUST include uploadpack.allowFilter in advertisement", 134 "MUST include uploadpack.allowFilter in advertisement",
134 ) 135 )
135 .fail(format!( 136 .fail(format!(
@@ -144,7 +145,7 @@ impl GitFilterTests {
144 Err(e) => { 145 Err(e) => {
145 return TestResult::new( 146 return TestResult::new(
146 test_name, 147 test_name,
147 "GRASP-01:git-http:42", 148 SpecRef::GitIncludeAllowSha1InWant,
148 "MUST include uploadpack.allowFilter in advertisement", 149 "MUST include uploadpack.allowFilter in advertisement",
149 ) 150 )
150 .fail(format!("Failed to read response body: {}", e)) 151 .fail(format!("Failed to read response body: {}", e))
@@ -155,7 +156,7 @@ impl GitFilterTests {
155 if !body.contains("filter") { 156 if !body.contains("filter") {
156 return TestResult::new( 157 return TestResult::new(
157 test_name, 158 test_name,
158 "GRASP-01:git-http:42", 159 SpecRef::GitIncludeAllowSha1InWant,
159 "MUST include uploadpack.allowFilter in advertisement", 160 "MUST include uploadpack.allowFilter in advertisement",
160 ) 161 )
161 .fail("Missing capability: filter"); 162 .fail("Missing capability: filter");
@@ -163,7 +164,7 @@ impl GitFilterTests {
163 164
164 TestResult::new( 165 TestResult::new(
165 test_name, 166 test_name,
166 "GRASP-01:git-http:42", 167 SpecRef::GitIncludeAllowSha1InWant,
167 "MUST include uploadpack.allowFilter in advertisement", 168 "MUST include uploadpack.allowFilter in advertisement",
168 ) 169 )
169 .pass() 170 .pass()
@@ -184,12 +185,12 @@ impl GitFilterTests {
184 let ctx = TestContext::new(client); 185 let ctx = TestContext::new(client);
185 186
186 // Create repository announcement 187 // Create repository announcement
187 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 188 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
188 Ok(r) => r, 189 Ok(r) => r,
189 Err(e) => { 190 Err(e) => {
190 return TestResult::new( 191 return TestResult::new(
191 test_name, 192 test_name,
192 "GRASP-01:git-http:42", 193 SpecRef::GitIncludeAllowSha1InWant,
193 "MUST serve filtered clone requests", 194 "MUST serve filtered clone requests",
194 ) 195 )
195 .fail(format!("Failed to create repo fixture: {}", e)) 196 .fail(format!("Failed to create repo fixture: {}", e))
@@ -243,7 +244,7 @@ impl GitFilterTests {
243 cleanup(); 244 cleanup();
244 return TestResult::new( 245 return TestResult::new(
245 test_name, 246 test_name,
246 "GRASP-01:git-http:42", 247 SpecRef::GitIncludeAllowSha1InWant,
247 "MUST serve filtered clone requests", 248 "MUST serve filtered clone requests",
248 ) 249 )
249 .fail(format!("Failed to execute git clone: {}", e)); 250 .fail(format!("Failed to execute git clone: {}", e));
@@ -255,7 +256,7 @@ impl GitFilterTests {
255 let stderr = String::from_utf8_lossy(&output.stderr); 256 let stderr = String::from_utf8_lossy(&output.stderr);
256 return TestResult::new( 257 return TestResult::new(
257 test_name, 258 test_name,
258 "GRASP-01:git-http:42", 259 SpecRef::GitIncludeAllowSha1InWant,
259 "MUST serve filtered clone requests", 260 "MUST serve filtered clone requests",
260 ) 261 )
261 .fail(format!("Filtered git clone failed: {}", stderr)); 262 .fail(format!("Filtered git clone failed: {}", stderr));
@@ -266,7 +267,7 @@ impl GitFilterTests {
266 cleanup(); 267 cleanup();
267 return TestResult::new( 268 return TestResult::new(
268 test_name, 269 test_name,
269 "GRASP-01:git-http:42", 270 SpecRef::GitIncludeAllowSha1InWant,
270 "MUST serve filtered clone requests", 271 "MUST serve filtered clone requests",
271 ) 272 )
272 .fail("Filtered clone missing .git directory"); 273 .fail("Filtered clone missing .git directory");
@@ -275,7 +276,7 @@ impl GitFilterTests {
275 cleanup(); 276 cleanup();
276 TestResult::new( 277 TestResult::new(
277 test_name, 278 test_name,
278 "GRASP-01:git-http:42", 279 SpecRef::GitIncludeAllowSha1InWant,
279 "MUST serve filtered clone requests", 280 "MUST serve filtered clone requests",
280 ) 281 )
281 .pass() 282 .pass()
@@ -295,12 +296,12 @@ impl GitFilterTests {
295 let ctx = TestContext::new(client); 296 let ctx = TestContext::new(client);
296 297
297 // Create repository announcement 298 // Create repository announcement
298 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 299 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
299 Ok(r) => r, 300 Ok(r) => r,
300 Err(e) => { 301 Err(e) => {
301 return TestResult::new( 302 return TestResult::new(
302 test_name, 303 test_name,
303 "GRASP-01:git-http:42", 304 SpecRef::GitIncludeAllowSha1InWant,
304 "MUST serve filtered fetch requests", 305 "MUST serve filtered fetch requests",
305 ) 306 )
306 .fail(format!("Failed to create repo fixture: {}", e)) 307 .fail(format!("Failed to create repo fixture: {}", e))
@@ -352,7 +353,7 @@ impl GitFilterTests {
352 cleanup(); 353 cleanup();
353 return TestResult::new( 354 return TestResult::new(
354 test_name, 355 test_name,
355 "GRASP-01:git-http:42", 356 SpecRef::GitIncludeAllowSha1InWant,
356 "MUST serve filtered fetch requests", 357 "MUST serve filtered fetch requests",
357 ) 358 )
358 .fail("Failed to create initial shallow clone for fetch test"); 359 .fail("Failed to create initial shallow clone for fetch test");
@@ -371,7 +372,7 @@ impl GitFilterTests {
371 cleanup(); 372 cleanup();
372 return TestResult::new( 373 return TestResult::new(
373 test_name, 374 test_name,
374 "GRASP-01:git-http:42", 375 SpecRef::GitIncludeAllowSha1InWant,
375 "MUST serve filtered fetch requests", 376 "MUST serve filtered fetch requests",
376 ) 377 )
377 .fail(format!("Failed to execute git fetch: {}", e)); 378 .fail(format!("Failed to execute git fetch: {}", e));
@@ -383,7 +384,7 @@ impl GitFilterTests {
383 let stderr = String::from_utf8_lossy(&output.stderr); 384 let stderr = String::from_utf8_lossy(&output.stderr);
384 return TestResult::new( 385 return TestResult::new(
385 test_name, 386 test_name,
386 "GRASP-01:git-http:42", 387 SpecRef::GitIncludeAllowSha1InWant,
387 "MUST serve filtered fetch requests", 388 "MUST serve filtered fetch requests",
388 ) 389 )
389 .fail(format!("Filtered git fetch failed: {}", stderr)); 390 .fail(format!("Filtered git fetch failed: {}", stderr));
@@ -392,7 +393,7 @@ impl GitFilterTests {
392 cleanup(); 393 cleanup();
393 TestResult::new( 394 TestResult::new(
394 test_name, 395 test_name,
395 "GRASP-01:git-http:42", 396 SpecRef::GitIncludeAllowSha1InWant,
396 "MUST serve filtered fetch requests", 397 "MUST serve filtered fetch requests",
397 ) 398 )
398 .pass() 399 .pass()
diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs
index 0a819ee..1694f58 100644
--- a/grasp-audit/src/specs/grasp01/mod.rs
+++ b/grasp-audit/src/specs/grasp01/mod.rs
@@ -19,6 +19,7 @@ pub mod git_clone;
19pub mod git_filter; 19pub mod git_filter;
20pub mod nip01_smoke; 20pub mod nip01_smoke;
21pub mod nip11_document; 21pub mod nip11_document;
22pub mod purgatory;
22pub mod push_authorization; 23pub mod push_authorization;
23pub mod repository_creation; 24pub mod repository_creation;
24pub mod spec_requirements; 25pub mod spec_requirements;
@@ -29,9 +30,10 @@ pub use git_clone::GitCloneTests;
29pub use git_filter::GitFilterTests; 30pub use git_filter::GitFilterTests;
30pub use nip01_smoke::Nip01SmokeTests; 31pub use nip01_smoke::Nip01SmokeTests;
31pub use nip11_document::Nip11DocumentTests; 32pub use nip11_document::Nip11DocumentTests;
33pub use purgatory::PurgatoryTests;
32pub use push_authorization::PushAuthorizationTests; 34pub use push_authorization::PushAuthorizationTests;
33pub use repository_creation::RepositoryCreationTests; 35pub use repository_creation::RepositoryCreationTests;
34pub use spec_requirements::{ 36pub use spec_requirements::{
35 get_requirement, get_requirements_for_section, get_sections, RequirementLevel, SpecRequirement, 37 get_requirement, get_requirement_by_ref, get_requirements_for_section, get_sections,
36 GRASP_01_REQUIREMENTS, GRASP_COMMIT_ID, 38 RequirementLevel, SpecRef, SpecRequirement, GRASP_01_REQUIREMENTS, GRASP_COMMIT_ID,
37}; 39};
diff --git a/grasp-audit/src/specs/grasp01/nip01_smoke.rs b/grasp-audit/src/specs/grasp01/nip01_smoke.rs
index 4d0b8a4..e3206fc 100644
--- a/grasp-audit/src/specs/grasp01/nip01_smoke.rs
+++ b/grasp-audit/src/specs/grasp01/nip01_smoke.rs
@@ -4,6 +4,7 @@
4//! We don't comprehensively test NIP-01 because rust-nostr already has 1000+ tests. 4//! We don't comprehensively test NIP-01 because rust-nostr already has 1000+ tests.
5//! These are just smoke tests to ensure the relay is working at all. 5//! These are just smoke tests to ensure the relay is working at all.
6 6
7use crate::specs::grasp01::SpecRef;
7use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; 8use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
8use nostr_sdk::prelude::*; 9use nostr_sdk::prelude::*;
9 10
@@ -32,8 +33,8 @@ impl Nip01SmokeTests {
32 pub async fn test_websocket_connection(client: &AuditClient) -> TestResult { 33 pub async fn test_websocket_connection(client: &AuditClient) -> TestResult {
33 TestResult::new( 34 TestResult::new(
34 "websocket_connection", 35 "websocket_connection",
35 "GRASP-01:nostr-relay:7", 36 SpecRef::NostrRelayNip01Compliant,
36 "Can establish WebSocket connection to /", 37 "MUST serve a relay at / via WebSocket",
37 ) 38 )
38 .run(|| async { 39 .run(|| async {
39 if !client.is_connected().await { 40 if !client.is_connected().await {
@@ -61,16 +62,16 @@ impl Nip01SmokeTests {
61 pub async fn test_send_receive_event(client: &AuditClient) -> TestResult { 62 pub async fn test_send_receive_event(client: &AuditClient) -> TestResult {
62 TestResult::new( 63 TestResult::new(
63 "send_receive_event", 64 "send_receive_event",
64 "GRASP-01:nostr-relay:7", 65 SpecRef::NostrRelayNip01Compliant,
65 "Can send EVENT and receive OK response", 66 "MUST accept valid EVENT messages",
66 ) 67 )
67 .run(|| async { 68 .run(|| async {
68 // Step 1: GENERATE - Create TestContext and get ValidRepo fixture 69 // Step 1: GENERATE - Create TestContext and get ValidRepoServed fixture
69 let ctx = TestContext::new(client); 70 let ctx = TestContext::new(client);
70 let event = ctx 71 let event = ctx
71 .get_fixture(FixtureKind::ValidRepo) 72 .get_fixture(FixtureKind::ValidRepoServed)
72 .await 73 .await
73 .map_err(|e| format!("Failed to create ValidRepo fixture: {}", e))?; 74 .map_err(|e| format!("Failed to create ValidRepoServed fixture: {}", e))?;
74 75
75 let event_id = event.id; 76 let event_id = event.id;
76 77
@@ -121,22 +122,22 @@ impl Nip01SmokeTests {
121 /// 122 ///
122 /// ## Fixture-First Pattern 123 /// ## Fixture-First Pattern
123 /// 124 ///
124 /// 1. **Generate**: Create TestContext and get ValidRepo fixture 125 /// 1. **Generate**: Create TestContext and get ValidRepoServed fixture
125 /// 2. **Send**: Fixture already sends the event to relay 126 /// 2. **Send**: Fixture already sends the event to relay
126 /// 3. **Verify**: Subscribe and verify we receive the event 127 /// 3. **Verify**: Subscribe and verify we receive the event
127 pub async fn test_create_subscription(client: &AuditClient) -> TestResult { 128 pub async fn test_create_subscription(client: &AuditClient) -> TestResult {
128 TestResult::new( 129 TestResult::new(
129 "create_subscription", 130 "create_subscription",
130 "GRASP-01:nostr-relay:7", 131 SpecRef::NostrRelayNip01Compliant,
131 "Can create subscription with REQ and receive EOSE", 132 "MUST support REQ subscriptions",
132 ) 133 )
133 .run(|| async { 134 .run(|| async {
134 // Step 1: GENERATE - Create TestContext and get ValidRepo fixture 135 // Step 1: GENERATE - Create TestContext and get ValidRepoServed fixture
135 let ctx = TestContext::new(client); 136 let ctx = TestContext::new(client);
136 let _event = ctx 137 let _event = ctx
137 .get_fixture(FixtureKind::ValidRepo) 138 .get_fixture(FixtureKind::ValidRepoServed)
138 .await 139 .await
139 .map_err(|e| format!("Failed to create ValidRepo fixture: {}", e))?; 140 .map_err(|e| format!("Failed to create ValidRepoServed fixture: {}", e))?;
140 141
141 // Step 2: VERIFY - Subscribe to NIP-34 announcements from this author 142 // Step 2: VERIFY - Subscribe to NIP-34 announcements from this author
142 let filter = Filter::new() 143 let filter = Filter::new()
@@ -165,8 +166,8 @@ impl Nip01SmokeTests {
165 pub async fn test_close_subscription(client: &AuditClient) -> TestResult { 166 pub async fn test_close_subscription(client: &AuditClient) -> TestResult {
166 TestResult::new( 167 TestResult::new(
167 "close_subscription", 168 "close_subscription",
168 "GRASP-01:nostr-relay:7", 169 SpecRef::NostrRelayNip01Compliant,
169 "Can close subscriptions", 170 "MUST support CLOSE to end subscriptions",
170 ) 171 )
171 .run(|| async { 172 .run(|| async {
172 // For now, we just verify we can query events 173 // For now, we just verify we can query events
@@ -193,8 +194,8 @@ impl Nip01SmokeTests {
193 pub async fn test_reject_invalid_signature(client: &AuditClient) -> TestResult { 194 pub async fn test_reject_invalid_signature(client: &AuditClient) -> TestResult {
194 TestResult::new( 195 TestResult::new(
195 "reject_invalid_signature", 196 "reject_invalid_signature",
196 "GRASP-01:nostr-relay:7", 197 SpecRef::NostrRelayNip01Compliant,
197 "Rejects events with invalid signatures", 198 "MUST reject events with invalid signatures",
198 ) 199 )
199 .run(|| async { 200 .run(|| async {
200 // Create a valid event 201 // Create a valid event
@@ -247,8 +248,8 @@ impl Nip01SmokeTests {
247 pub async fn test_reject_invalid_event_id(client: &AuditClient) -> TestResult { 248 pub async fn test_reject_invalid_event_id(client: &AuditClient) -> TestResult {
248 TestResult::new( 249 TestResult::new(
249 "reject_invalid_event_id", 250 "reject_invalid_event_id",
250 "GRASP-01:nostr-relay:7", 251 SpecRef::NostrRelayNip01Compliant,
251 "Rejects events with invalid event IDs", 252 "MUST reject events where ID doesn't match hash",
252 ) 253 )
253 .run(|| async { 254 .run(|| async {
254 // Create a valid event 255 // Create a valid event
diff --git a/grasp-audit/src/specs/grasp01/nip11_document.rs b/grasp-audit/src/specs/grasp01/nip11_document.rs
index 19ceace..5bf53bd 100644
--- a/grasp-audit/src/specs/grasp01/nip11_document.rs
+++ b/grasp-audit/src/specs/grasp01/nip11_document.rs
@@ -8,6 +8,7 @@
8//! - Includes repo_acceptance_criteria field describing acceptance policy 8//! - Includes repo_acceptance_criteria field describing acceptance policy
9//! - Handles curation field correctly (present if curated, absent otherwise) 9//! - Handles curation field correctly (present if curated, absent otherwise)
10 10
11use crate::specs::grasp01::SpecRef;
11use crate::{AuditClient, AuditResult, TestResult}; 12use crate::{AuditClient, AuditResult, TestResult};
12 13
13pub struct Nip11DocumentTests; 14pub struct Nip11DocumentTests;
@@ -37,8 +38,8 @@ impl Nip11DocumentTests {
37 pub async fn test_nip11_document_exists(client: &AuditClient) -> TestResult { 38 pub async fn test_nip11_document_exists(client: &AuditClient) -> TestResult {
38 TestResult::new( 39 TestResult::new(
39 "nip11_document_exists", 40 "nip11_document_exists",
40 "GRASP-01:nostr-relay:26", 41 SpecRef::Nip11ServeDocument,
41 "Serve NIP-11 relay information document", 42 "MUST serve NIP-11 document",
42 ) 43 )
43 .run(|| async { 44 .run(|| async {
44 // 1. Extract HTTP(S) URL from client's WebSocket URL 45 // 1. Extract HTTP(S) URL from client's WebSocket URL
@@ -96,8 +97,8 @@ impl Nip11DocumentTests {
96 pub async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult { 97 pub async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult {
97 TestResult::new( 98 TestResult::new(
98 "nip11_supported_grasps_field", 99 "nip11_supported_grasps_field",
99 "GRASP-01:nostr-relay:28", 100 SpecRef::Nip11ListSupportedGrasps,
100 "NIP-11 document includes supported_grasps field with GRASP-01", 101 "MUST list supported GRASPs as string array",
101 ) 102 )
102 .run(|| async { 103 .run(|| async {
103 // 1. Fetch NIP-11 document 104 // 1. Fetch NIP-11 document
@@ -172,8 +173,8 @@ impl Nip11DocumentTests {
172 pub async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult { 173 pub async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult {
173 TestResult::new( 174 TestResult::new(
174 "nip11_repo_acceptance_criteria_field", 175 "nip11_repo_acceptance_criteria_field",
175 "GRASP-01:nostr-relay:29", 176 SpecRef::Nip11ListRepoAcceptanceCriteria,
176 "NIP-11 document includes repo_acceptance_criteria field", 177 "MUST list repository acceptance criteria",
177 ) 178 )
178 .run(|| async { 179 .run(|| async {
179 // 1. Fetch NIP-11 document 180 // 1. Fetch NIP-11 document
@@ -227,8 +228,8 @@ impl Nip11DocumentTests {
227 pub async fn test_nip11_curation_field(client: &AuditClient) -> TestResult { 228 pub async fn test_nip11_curation_field(client: &AuditClient) -> TestResult {
228 TestResult::new( 229 TestResult::new(
229 "nip11_curation_field", 230 "nip11_curation_field",
230 "GRASP-01:nostr-relay:30", 231 SpecRef::Nip11ListCurationPolicy,
231 "NIP-11 curation field present if curated, absent otherwise", 232 "MUST include curation if curated, omit otherwise",
232 ) 233 )
233 .run(|| async { 234 .run(|| async {
234 // 1. Fetch NIP-11 document 235 // 1. Fetch NIP-11 document
diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs
new file mode 100644
index 0000000..29eabad
--- /dev/null
+++ b/grasp-audit/src/specs/grasp01/purgatory.rs
@@ -0,0 +1,983 @@
1//! GRASP-01 Purgatory Tests
2//!
3//! Tests for the GRASP-01 purgatory mechanism where events are accepted but not
4//! served until corresponding git data arrives.
5//!
6//! ## Purgatory Behavior (GRASP-01 Line 22)
7//!
8//! "New repository announcements, repo state announcements, PRs and PR Updates
9//! SHOULD be accepted with message 'purgatory: won't be served until git data arrives'
10//! and kept in purgatory (not served) until the related git data arrives and otherwise
11//! discarded after 30 minutes."
12//!
13//! ## Test Categories
14//!
15//! ### Announcement Purgatory (feature not yet implemented)
16//! - `test_announcement_not_served_before_git_data`
17//! - `test_announcement_served_after_git_push`
18//! - `test_bare_repo_exists_for_purgatory_announcement`
19//! - `test_state_event_accepted_for_purgatory_announcement`
20//!
21//! ### State Event Purgatory (already implemented)
22//! - `test_state_event_not_served_before_git_data`
23//! - `test_state_event_served_after_git_push`
24//!
25//! ### PR Purgatory (already implemented)
26//! - `test_pr_event_accepted_into_purgatory` - Event accepted, not queryable
27//! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds
28//! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data
29
30use crate::fixtures::{clone_repo, create_commit, try_push};
31use crate::specs::grasp01::SpecRef;
32use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
33use nostr_sdk::prelude::*;
34use std::fs;
35use std::time::Duration;
36
37/// Test suite for GRASP-01 purgatory behavior
38pub struct PurgatoryTests;
39
40impl PurgatoryTests {
41 /// Run all purgatory tests
42 pub async fn run_all(client: &AuditClient) -> AuditResult {
43 let mut results = AuditResult::new("GRASP-01 Purgatory Tests");
44
45 // Announcement purgatory tests (feature not yet implemented)
46 results.add(Self::test_announcement_not_served_before_git_data(client).await);
47 results.add(Self::test_announcement_served_after_git_push(client).await);
48 results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await);
49 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
50
51 // Deletion event tests (NIP-09)
52 results.add(Self::test_deletion_by_event_id_removes_purgatory_state_event(client).await);
53 results.add(
54 Self::test_deletion_by_coordinate_removes_purgatory_state_event(client).await,
55 );
56
57 // State event purgatory tests (already implemented)
58 results.add(Self::test_state_event_not_served_before_git_data(client).await);
59 results.add(Self::test_state_event_served_after_git_push(client).await);
60
61 // PR purgatory tests
62 results.add(Self::test_pr_event_accepted_into_purgatory_and_isnt_served(client).await);
63 results.add(Self::test_pr_event_in_purgatory_git_push_accepted(client).await);
64 results.add(Self::test_pr_event_served_after_git_push(client).await);
65
66 results
67 }
68
69 // ============================================================
70 // Announcement Purgatory Tests (#[ignore] - feature not yet implemented)
71 // ============================================================
72
73 /// Test: Repository announcement not served before git data arrives
74 ///
75 /// Spec: GRASP-01 Line 22
76 /// "New repository announcements... SHOULD be accepted with message
77 /// 'purgatory: won't be served until git data arrives' and kept in purgatory
78 /// (not served) until the related git data arrives"
79 ///
80 /// This test verifies:
81 /// 1. Send a valid repository announcement
82 /// 2. Event is accepted (OK response)
83 /// 3. Event is NOT queryable from the relay (in purgatory)
84 ///
85 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
86 pub async fn test_announcement_not_served_before_git_data(client: &AuditClient) -> TestResult {
87 TestResult::new(
88 "announcement_not_served_before_git_data",
89 SpecRef::PurgatoryAcceptUntilGitData,
90 "Repository announcements SHOULD be accepted but not served until git data arrives",
91 )
92 .run(|| async {
93 let ctx = TestContext::new(client);
94
95 // Create a fresh repo announcement (not the served variant)
96 let repo = ctx
97 .get_fixture(FixtureKind::ValidRepoSent)
98 .await
99 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
100
101 let repo_id = repo
102 .tags
103 .iter()
104 .find(|t| t.kind() == TagKind::d())
105 .and_then(|t| t.content())
106 .ok_or("Missing d tag in repo announcement")?
107 .to_string();
108
109 // Query for the announcement - should NOT be served
110 let filter = Filter::new()
111 .kind(Kind::GitRepoAnnouncement)
112 .author(client.public_key())
113 .identifier(&repo_id);
114
115 tokio::time::sleep(Duration::from_millis(300)).await;
116
117 let events = client
118 .query(filter)
119 .await
120 .map_err(|e| format!("Failed to query relay: {}", e))?;
121
122 if events.iter().any(|e| e.id == repo.id) {
123 return Err(format!(
124 "Announcement was served immediately - purgatory not implemented. \
125 Event ID: {} should NOT be queryable until git data arrives",
126 repo.id
127 ));
128 }
129
130 Ok(())
131 })
132 .await
133 }
134
135 /// Test: Repository announcement served after git push
136 ///
137 /// Spec: GRASP-01 Line 22
138 /// "...kept in purgatory (not served) until the related git data arrives"
139 ///
140 /// This test verifies the full lifecycle:
141 /// 1. Send repository announcement (enters purgatory)
142 /// 2. Send state event (enters purgatory)
143 /// 3. Push git data matching state event
144 /// 4. Both announcement and state event are now served
145 ///
146 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
147 pub async fn test_announcement_served_after_git_push(client: &AuditClient) -> TestResult {
148 TestResult::new(
149 "announcement_served_after_git_push",
150 SpecRef::PurgatoryAcceptUntilGitData,
151 "Repository announcements SHOULD be served after git data arrives",
152 )
153 .run(|| async {
154 let ctx = TestContext::new(client);
155
156 // OwnerStateDataPushed fixture handles the full lifecycle:
157 // 1. Creates repo announcement (purgatory)
158 // 2. Creates state event (purgatory)
159 // 3. Pushes git data
160 // 4. Verifies events are served
161 let state_event = ctx
162 .get_fixture(FixtureKind::OwnerStateDataPushed)
163 .await
164 .map_err(|e| format!("Failed to complete full lifecycle: {}", e))?;
165
166 // Extract repo_id from state event
167 let repo_id = state_event
168 .tags
169 .iter()
170 .find(|t| t.kind() == TagKind::d())
171 .and_then(|t| t.content())
172 .ok_or("Missing d tag in state event")?
173 .to_string();
174
175 // Verify announcement is now served
176 let announcement_filter = Filter::new()
177 .kind(Kind::GitRepoAnnouncement)
178 .author(client.public_key())
179 .identifier(&repo_id);
180
181 let announcements = client
182 .query(announcement_filter)
183 .await
184 .map_err(|e| format!("Failed to query announcements: {}", e))?;
185
186 if announcements.is_empty() {
187 return Err(format!(
188 "Announcement not served after git push. Repo ID: {}",
189 repo_id
190 ));
191 }
192
193 // Verify state event is served
194 let state_filter = Filter::new()
195 .kind(Kind::RepoState)
196 .author(client.public_key())
197 .identifier(&repo_id);
198
199 let state_events = client
200 .query(state_filter)
201 .await
202 .map_err(|e| format!("Failed to query state events: {}", e))?;
203
204 if !state_events.iter().any(|e| e.id == state_event.id) {
205 return Err(format!(
206 "State event not served after git push. Event ID: {}",
207 state_event.id
208 ));
209 }
210
211 Ok(())
212 })
213 .await
214 }
215
216 /// Test: Bare repository exists for purgatory announcement
217 ///
218 /// Spec: GRASP-01 Line 34
219 /// "MUST serve a git repository via an unauthenticated git smart http service
220 /// at `/<npub>/<identifier>.git` for each git repository announcement the relay
221 /// serves or has in purgatory."
222 ///
223 /// This test verifies that git HTTP service works even for repos in purgatory.
224 ///
225 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
226 pub async fn test_bare_repo_exists_for_purgatory_announcement(
227 client: &AuditClient,
228 ) -> TestResult {
229 TestResult::new(
230 "bare_repo_exists_for_purgatory_announcement",
231 SpecRef::GitServeRepository,
232 "Git HTTP service MUST work for repos in purgatory",
233 )
234 .run(|| async {
235 let ctx = TestContext::new(client);
236
237 // Get a repo announcement (in purgatory, no git data yet)
238 let repo = ctx
239 .get_fixture(FixtureKind::ValidRepoSent)
240 .await
241 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
242
243 let repo_id = repo
244 .tags
245 .iter()
246 .find(|t| t.kind() == TagKind::d())
247 .and_then(|t| t.content())
248 .ok_or("Missing d tag in repo announcement")?
249 .to_string();
250
251 let npub = client
252 .public_key()
253 .to_bech32()
254 .map_err(|e| format!("Failed to convert pubkey: {}", e))?;
255
256 // Get relay domain
257 let relay_url = client
258 .client()
259 .relays()
260 .await
261 .keys()
262 .next()
263 .ok_or("No relay connected")?
264 .to_string();
265 let relay_domain = relay_url
266 .replace("ws://", "")
267 .replace("wss://", "")
268 .replace(":8080", "");
269
270 // Check git HTTP service is available
271 let info_refs_url = format!(
272 "http://{}/{}/{}.git/info/refs?service=git-upload-pack",
273 relay_domain, npub, repo_id
274 );
275
276 let http_client = reqwest::Client::new();
277 let response = http_client
278 .get(&info_refs_url)
279 .send()
280 .await
281 .map_err(|e| format!("HTTP request failed: {}", e))?;
282
283 if !response.status().is_success() {
284 return Err(format!(
285 "Git HTTP service not available for purgatory repo. \
286 URL: {}, Status: {}",
287 info_refs_url,
288 response.status()
289 ));
290 }
291
292 Ok(())
293 })
294 .await
295 }
296
297 /// Test: State event accepted for purgatory announcement
298 ///
299 /// Spec: GRASP-01 Line 22
300 /// "New repository announcements, repo state announcements... SHOULD be accepted"
301 ///
302 /// This test verifies that state events are accepted even when the repo
303 /// announcement is in purgatory (no git data yet).
304 ///
305 /// NOTE: Announcement purgatory feature not yet implemented - test may fail
306 pub async fn test_state_event_accepted_for_purgatory_announcement(
307 client: &AuditClient,
308 ) -> TestResult {
309 TestResult::new(
310 "state_event_accepted_for_purgatory_announcement",
311 SpecRef::PurgatoryAcceptUntilGitData,
312 "State events SHOULD be accepted for repos in purgatory",
313 )
314 .run(|| async {
315 let ctx = TestContext::new(client);
316
317 // Get a repo announcement (in purgatory)
318 let repo = ctx
319 .get_fixture(FixtureKind::ValidRepoSent)
320 .await
321 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
322
323 // Build a state event for this repo
324 let repo_id = repo
325 .tags
326 .iter()
327 .find(|t| t.kind() == TagKind::d())
328 .and_then(|t| t.content())
329 .ok_or("Missing d tag in repo announcement")?
330 .to_string();
331
332 let state_event = client
333 .event_builder(Kind::RepoState, "")
334 .tag(Tag::identifier(&repo_id))
335 .tag(Tag::custom(
336 TagKind::custom("refs/heads/main"),
337 vec!["abc123".to_string()],
338 ))
339 .tag(Tag::custom(
340 TagKind::custom("HEAD"),
341 vec!["ref: refs/heads/main".to_string()],
342 ))
343 .build(client.keys())
344 .map_err(|e| format!("Failed to build state event: {}", e))?;
345
346 // Send state event - should be accepted (even though repo is in purgatory)
347 let (_, in_purgatory) = client
348 .send_event_and_note_purgatory(state_event.clone())
349 .await
350 .map_err(|e| format!("Failed to send state event: {}", e))?;
351
352 // Event should be accepted (either in purgatory or served)
353 // We just verify it wasn't rejected
354 if !in_purgatory {
355 // Check if it's actually on the relay (might be served immediately)
356 let filter = Filter::new()
357 .kind(Kind::RepoState)
358 .author(client.public_key())
359 .identifier(&repo_id);
360
361 let events = client
362 .query(filter)
363 .await
364 .map_err(|e| format!("Failed to query: {}", e))?;
365
366 if events.iter().any(|e| e.id == state_event.id) {
367 return Err(format!(
368 "State event was served immediately - repo announcement purgatory not implemented. \
369 Event ID: {} should NOT be queryable until git data arrives",
370 state_event.id
371 ));
372 }
373
374 return Err(format!(
375 "State event was neither in purgatory nor served. \
376 Event ID: {}",
377 state_event.id
378 ));
379 }
380
381 // Feature IS implemented - state event in purgatory as expected
382 Ok(())
383 })
384 .await
385 }
386
387 // ============================================================
388 // State Event Purgatory Tests (non-ignored - already implemented)
389 // ============================================================
390
391 /// Test: State event not served before git data arrives
392 ///
393 /// Spec: GRASP-01 Line 22
394 /// "repo state announcements... SHOULD be accepted with message
395 /// 'purgatory: won't be served until git data arrives'"
396 ///
397 /// This test verifies:
398 /// 1. Send state event for a repo with git data
399 /// 2. State event points to a different commit than what's pushed
400 /// 3. State event is NOT queryable (in purgatory)
401 pub async fn test_state_event_not_served_before_git_data(client: &AuditClient) -> TestResult {
402 TestResult::new(
403 "state_event_not_served_before_git_data",
404 SpecRef::PurgatoryAcceptUntilGitData,
405 "State events SHOULD be accepted but not served until git data arrives",
406 )
407 .run(|| async {
408 let ctx = TestContext::new(client);
409
410 // Get a repo with git data already pushed
411 let existing_state = ctx
412 .get_fixture(FixtureKind::OwnerStateDataPushed)
413 .await
414 .map_err(|e| format!("Failed to get existing repo: {}", e))?;
415
416 let repo_id = existing_state
417 .tags
418 .iter()
419 .find(|t| t.kind() == TagKind::d())
420 .and_then(|t| t.content())
421 .ok_or("Missing d tag in state event")?
422 .to_string();
423
424 // Create a NEW state event pointing to a DIFFERENT commit
425 // This should enter purgatory since the commit doesn't exist
426 let new_state = client
427 .event_builder(Kind::RepoState, "")
428 .tag(Tag::identifier(&repo_id))
429 .tag(Tag::custom(
430 TagKind::custom("refs/heads/main"),
431 vec!["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string()],
432 ))
433 .tag(Tag::custom(
434 TagKind::custom("HEAD"),
435 vec!["ref: refs/heads/main".to_string()],
436 ))
437 .build(client.keys())
438 .map_err(|e| format!("Failed to build state event: {}", e))?;
439
440 // Send the state event
441 let (_, in_purgatory) = client
442 .send_event_and_note_purgatory(new_state.clone())
443 .await
444 .map_err(|e| format!("Failed to send state event: {}", e))?;
445
446 if !in_purgatory {
447 return Err(format!(
448 "State event was served immediately despite pointing to \
449 non-existent commit. Event ID: {}",
450 new_state.id
451 ));
452 }
453
454 Ok(())
455 })
456 .await
457 }
458
459 /// Test: State event served after git push
460 ///
461 /// Spec: GRASP-01 Line 22
462 /// "...kept in purgatory (not served) until the related git data arrives"
463 ///
464 /// This test verifies the full lifecycle using OwnerStateDataPushed fixture:
465 /// 1. State event is sent (enters purgatory)
466 /// 2. Git data is pushed matching the state event
467 /// 3. State event is now served
468 pub async fn test_state_event_served_after_git_push(client: &AuditClient) -> TestResult {
469 TestResult::new(
470 "state_event_served_after_git_push",
471 SpecRef::PurgatoryAcceptUntilGitData,
472 "State events SHOULD be served after matching git data arrives",
473 )
474 .run(|| async {
475 let ctx = TestContext::new(client);
476
477 // OwnerStateDataPushed handles the full lifecycle
478 let state_event = ctx
479 .get_fixture(FixtureKind::OwnerStateDataPushed)
480 .await
481 .map_err(|e| format!("Failed to complete full lifecycle: {}", e))?;
482
483 // Verify state event is now served
484 let repo_id = state_event
485 .tags
486 .iter()
487 .find(|t| t.kind() == TagKind::d())
488 .and_then(|t| t.content())
489 .ok_or("Missing d tag in state event")?
490 .to_string();
491
492 let filter = Filter::new()
493 .kind(Kind::RepoState)
494 .author(client.public_key())
495 .identifier(&repo_id);
496
497 let events = client
498 .query(filter)
499 .await
500 .map_err(|e| format!("Failed to query state events: {}", e))?;
501
502 if !events.iter().any(|e| e.id == state_event.id) {
503 return Err(format!(
504 "State event not served after git push. Event ID: {}",
505 state_event.id
506 ));
507 }
508
509 Ok(())
510 })
511 .await
512 }
513
514 // ============================================================
515 // PR Purgatory Tests
516 // ============================================================
517
518 /// Test: PR event accepted into purgatory (not served before git data)
519 ///
520 /// Spec: GRASP-01 Line 22
521 /// "PRs and PR Updates SHOULD be accepted with message
522 /// 'purgatory: won't be served until git data arrives'"
523 ///
524 /// This test verifies:
525 /// 1. PR event is sent and relay responds OK (accepted)
526 /// 2. PR event is NOT queryable (in purgatory, not served)
527 ///
528 /// PASS means: Relay accepted the event and is holding it in purgatory
529 /// FAIL means: Either event was rejected, or served immediately (purgatory not implemented)
530 ///
531 /// Note: This test cannot distinguish between "event in purgatory" and
532 /// "event accepted but never stored" - both result in event not being queryable.
533 /// The fixture verifies the relay responded OK, which is the best we can do
534 /// with black-box testing.
535 pub async fn test_pr_event_accepted_into_purgatory_and_isnt_served(
536 client: &AuditClient,
537 ) -> TestResult {
538 TestResult::new(
539 "pr_event_accepted_into_purgatory",
540 SpecRef::PurgatoryAcceptUntilGitData,
541 "PR event SHOULD be accepted but not served until git data arrives",
542 )
543 .run(|| async {
544 let ctx = TestContext::new(client);
545
546 // PREvent2Sent fixture:
547 // 1. Sends PR event
548 // 2. Verifies relay responded OK (not rejected)
549 // 3. Verifies event is NOT queryable (in purgatory)
550 let pr_event = ctx
551 .get_fixture(FixtureKind::PREvent2Sent)
552 .await
553 .map_err(|e| format!("Failed to send PR event: {}", e))?;
554
555 // Double-check: event should not be queryable
556 let filter = Filter::new()
557 .kind(Kind::GitPullRequest)
558 .author(client.pr_author_keys().public_key())
559 .id(pr_event.id);
560
561 tokio::time::sleep(Duration::from_millis(300)).await;
562
563 let events = client
564 .query(filter)
565 .await
566 .map_err(|e| format!("Failed to query PR events: {}", e))?;
567
568 if !events.is_empty() {
569 return Err(format!(
570 "PR event was served immediately - purgatory not implemented. Event ID: {}",
571 pr_event.id
572 ));
573 }
574
575 Ok(())
576 })
577 .await
578 }
579
580 /// Test: Git push to refs/nostr/<pr-event-id> is accepted
581 ///
582 /// This test verifies that pushing git data for a PR event in purgatory
583 /// is accepted by the relay.
584 ///
585 /// PASS means: Git push succeeded, relay accepted the git data
586 /// FAIL means: Git push was rejected (wrong ref, permissions, etc.)
587 pub async fn test_pr_event_in_purgatory_git_push_accepted(client: &AuditClient) -> TestResult {
588 TestResult::new(
589 "pr_event_in_purgatory_git_push_accepted",
590 SpecRef::PurgatoryAcceptUntilGitData,
591 "Git push for PR event SHOULD be accepted",
592 )
593 .run(|| async {
594 let ctx = TestContext::new(client);
595
596 // PREvent2GitDataPushed fixture:
597 // 1. Gets PR event in purgatory (PREvent2Sent)
598 // 2. Pushes commit to refs/nostr/<pr-event-id>
599 // 3. Verifies push succeeded
600 let _pr_event = ctx
601 .get_fixture(FixtureKind::PREvent2GitDataPushed)
602 .await
603 .map_err(|e| format!("Failed to push git data for PR event: {}", e))?;
604
605 Ok(())
606 })
607 .await
608 }
609
610 /// Test: PR event served after git data arrives
611 ///
612 /// This test verifies the full purgatory release mechanism:
613 /// after git data is pushed to refs/nostr/<pr-event-id>, the event
614 /// becomes queryable.
615 ///
616 /// PASS means: Event was released from purgatory and is now served
617 /// FAIL means: Event still not queryable after git push (purgatory release broken)
618 pub async fn test_pr_event_served_after_git_push(client: &AuditClient) -> TestResult {
619 TestResult::new(
620 "pr_event_served_after_git_push",
621 SpecRef::PurgatoryAcceptUntilGitData,
622 "PR event SHOULD be served after matching git data arrives",
623 )
624 .run(|| async {
625 let ctx = TestContext::new(client);
626
627 // PREvent2Served fixture:
628 // 1. Gets PR event with git data pushed (PREvent2GitDataPushed)
629 // 2. Verifies event is now queryable
630 let pr_event = ctx
631 .get_fixture(FixtureKind::PREvent2Served)
632 .await
633 .map_err(|e| format!("Failed to complete purgatory release: {}", e))?;
634
635 // Double-check: event should be queryable now
636 let filter = Filter::new()
637 .kind(Kind::GitPullRequest)
638 .author(client.pr_author_keys().public_key())
639 .id(pr_event.id);
640
641 let events = client
642 .query(filter)
643 .await
644 .map_err(|e| format!("Failed to query PR events: {}", e))?;
645
646 if events.is_empty() {
647 return Err(format!(
648 "PR event not served after git push. Event ID: {} should be queryable",
649 pr_event.id
650 ));
651 }
652
653 Ok(())
654 })
655 .await
656 }
657 // ============================================================
658 // Deletion Event Tests (NIP-09)
659 // ============================================================
660
661 /// Test: Kind 5 deletion event by event ID removes a purgatory state event
662 ///
663 /// Spec: NIP-09
664 /// "A special event with kind 5... having a list of one or more `e` or `a` tags,
665 /// each referencing an event the author is requesting to be deleted."
666 ///
667 /// This test verifies:
668 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
669 /// 2. Clone the repo and create a unique commit (not yet pushed)
670 /// 3. Submit a state event pointing to that unique commit (enters purgatory)
671 /// 4. Send a kind 5 deletion event referencing the state event by event ID
672 /// 5. Attempt to push the unique commit — MUST be rejected (no authorized state event)
673 pub async fn test_deletion_by_event_id_removes_purgatory_state_event(
674 client: &AuditClient,
675 ) -> TestResult {
676 TestResult::new(
677 "deletion_by_event_id_removes_purgatory_state_event",
678 SpecRef::PurgatoryAcceptUntilGitData,
679 "Kind 5 deletion by event ID SHOULD remove a purgatory state event, causing push rejection",
680 )
681 .run(|| async {
682 let ctx = TestContext::new(client);
683
684 // Stage 1: get a promoted repo with git data already on the relay
685 let existing_state = ctx
686 .get_fixture(FixtureKind::OwnerStateDataPushed)
687 .await
688 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
689
690 let repo_id = existing_state
691 .tags
692 .iter()
693 .find(|t| t.kind() == TagKind::d())
694 .and_then(|t| t.content())
695 .ok_or("Missing d tag in state event")?
696 .to_string();
697
698 let relay_domain = client
699 .relay_url()
700 .await
701 .map_err(|e| e.to_string())?
702 .trim_start_matches("ws://")
703 .trim_start_matches("wss://")
704 .to_string();
705
706 let npub = client
707 .public_key()
708 .to_bech32()
709 .map_err(|e| e.to_string())?;
710
711 // Stage 2: clone the repo and create a unique commit (not pushed yet)
712 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
713 .map_err(|e| format!("Failed to clone repo: {}", e))?;
714
715 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
716
717 let unique_commit = match create_commit(&clone_path, "deletion test unique commit") {
718 Ok(h) => h,
719 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
720 };
721
722 // Stage 3: submit a state event pointing to the unique commit (enters purgatory)
723 let state_event = client
724 .event_builder(Kind::RepoState, "")
725 .tag(Tag::identifier(&repo_id))
726 .tag(Tag::custom(
727 TagKind::custom("refs/heads/main"),
728 vec![unique_commit.clone()],
729 ))
730 .tag(Tag::custom(
731 TagKind::custom("HEAD"),
732 vec!["ref: refs/heads/main".to_string()],
733 ))
734 .build(client.keys())
735 .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
736
737 let (_, in_purgatory) = client
738 .send_event_and_note_purgatory(state_event.clone())
739 .await
740 .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
741
742 if !in_purgatory {
743 cleanup();
744 return Err(format!(
745 "State event was served immediately (not in purgatory). \
746 Commit {} may already exist on relay.",
747 unique_commit
748 ));
749 }
750
751 // Stage 4: send kind 5 deletion event referencing the state event by event ID
752 let deletion = client
753 .event_builder(Kind::EventDeletion, "")
754 .tag(Tag::event(state_event.id))
755 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
756 .build(client.keys())
757 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
758
759 client
760 .send_event(deletion)
761 .await
762 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
763
764 tokio::time::sleep(Duration::from_millis(300)).await;
765
766 // Stage 5: attempt to push the unique commit — must be rejected
767 let push_result = try_push(&clone_path);
768 cleanup();
769
770 match push_result {
771 Ok(false) => Ok(()), // push rejected as expected
772 Ok(true) => Err(format!(
773 "Push was accepted but should have been rejected. \
774 The state event (id={}) was deleted, so commit {} \
775 should not be authorized.",
776 state_event.id, unique_commit
777 )),
778 Err(e) => Err(format!("Git push error: {}", e)),
779 }
780 })
781 .await
782 }
783
784 /// Test: Kind 5 deletion event by `a` tag coordinate removes a purgatory state event
785 ///
786 /// Spec: NIP-09
787 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable
788 /// event up to the `created_at` timestamp of the deletion request event."
789 ///
790 /// This test verifies:
791 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
792 /// 2. Generate a fresh keypair for a new maintainer
793 /// 3. Send a replacement owner announcement adding the new maintainer (goes to DB)
794 /// 4. Send a state event signed by the new maintainer pointing to a unique commit
795 /// (enters purgatory — maintainer is authorized but commit doesn't exist yet)
796 /// 5. Delete by coordinate `30618:<new_maintainer_pubkey>:<identifier>`
797 /// 6. Clone repo, create that unique commit, attempt to push — MUST be rejected
798 /// (the state event was deleted, so the commit is no longer authorized)
799 pub async fn test_deletion_by_coordinate_removes_purgatory_state_event(
800 client: &AuditClient,
801 ) -> TestResult {
802 TestResult::new(
803 "deletion_by_coordinate_removes_purgatory_state_event",
804 SpecRef::PurgatoryAcceptUntilGitData,
805 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory state event, causing push rejection",
806 )
807 .run(|| async {
808 let ctx = TestContext::new(client);
809
810 // Stage 1: get a promoted repo with git data already on the relay
811 let existing_state = ctx
812 .get_fixture(FixtureKind::OwnerStateDataPushed)
813 .await
814 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
815
816 let repo_id = existing_state
817 .tags
818 .iter()
819 .find(|t| t.kind() == TagKind::d())
820 .and_then(|t| t.content())
821 .ok_or("Missing d tag in state event")?
822 .to_string();
823
824 // Stage 2: generate a fresh keypair for a new maintainer
825 let new_maintainer_keys = Keys::generate();
826 let new_maintainer_hex = new_maintainer_keys.public_key().to_hex();
827
828 // Stage 3: send a replacement owner announcement that adds the new maintainer.
829 // This is a replacement (same pubkey + identifier already in DB) so it goes
830 // straight to the database without entering purgatory.
831 let relay_url = client
832 .relay_url()
833 .await
834 .map_err(|e| e.to_string())?;
835 let http_url = relay_url
836 .replace("ws://", "http://")
837 .replace("wss://", "https://");
838 let npub = client
839 .public_key()
840 .to_bech32()
841 .map_err(|e| e.to_string())?;
842
843 let replacement_announcement = client
844 .event_builder(Kind::GitRepoAnnouncement, "")
845 .tag(Tag::identifier(&repo_id))
846 .tag(Tag::custom(
847 TagKind::custom("clone"),
848 vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
849 ))
850 .tag(Tag::custom(
851 TagKind::custom("relays"),
852 vec![relay_url.clone()],
853 ))
854 .tag(Tag::custom(
855 TagKind::custom("maintainers"),
856 vec![new_maintainer_hex.clone()],
857 ))
858 .build(client.keys())
859 .map_err(|e| format!("Failed to build replacement announcement: {}", e))?;
860
861 client
862 .send_event(replacement_announcement)
863 .await
864 .map_err(|e| format!("Relay rejected replacement announcement: {}", e))?;
865
866 tokio::time::sleep(Duration::from_millis(200)).await;
867
868 // Stage 4: clone the repo and create a unique commit (not pushed yet)
869 let relay_domain = relay_url
870 .trim_start_matches("ws://")
871 .trim_start_matches("wss://")
872 .to_string();
873
874 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
875 .map_err(|e| format!("Failed to clone repo: {}", e))?;
876
877 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
878
879 let unique_commit = match create_commit(&clone_path, "deletion coordinate test unique commit") {
880 Ok(h) => h,
881 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
882 };
883
884 // Stage 5: submit a state event signed by the new maintainer pointing to the
885 // unique commit. The new maintainer is now authorized (listed in the replacement
886 // announcement), so the state event should enter purgatory (commit doesn't exist).
887 let state_event = client
888 .event_builder(Kind::RepoState, "")
889 .tag(Tag::identifier(&repo_id))
890 .tag(Tag::custom(
891 TagKind::custom("refs/heads/main"),
892 vec![unique_commit.clone()],
893 ))
894 .tag(Tag::custom(
895 TagKind::custom("HEAD"),
896 vec!["ref: refs/heads/main".to_string()],
897 ))
898 .build(&new_maintainer_keys)
899 .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
900
901 let (_, in_purgatory) = client
902 .send_event_and_note_purgatory(state_event.clone())
903 .await
904 .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
905
906 if !in_purgatory {
907 cleanup();
908 return Err(format!(
909 "State event was served immediately (not in purgatory). \
910 Commit {} may already exist on relay.",
911 unique_commit
912 ));
913 }
914
915 // Stage 6: send kind 5 deletion event signed by the new maintainer,
916 // referencing their state event by coordinate `30618:<pubkey>:<identifier>`
917 let coord = format!("30618:{}:{}", new_maintainer_hex, repo_id);
918
919 let deletion = client
920 .event_builder(Kind::EventDeletion, "")
921 .tag(Tag::custom(TagKind::custom("a"), vec![coord]))
922 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
923 .build(&new_maintainer_keys)
924 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
925
926 client
927 .send_event(deletion)
928 .await
929 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
930
931 tokio::time::sleep(Duration::from_millis(300)).await;
932
933 // Stage 7: attempt to push the unique commit — must be rejected because
934 // the new maintainer's state event was deleted from purgatory
935 let push_result = try_push(&clone_path);
936 cleanup();
937
938 match push_result {
939 Ok(false) => Ok(()), // push rejected as expected
940 Ok(true) => Err(format!(
941 "Push was accepted but should have been rejected. \
942 The new maintainer's state event (id={}) was deleted by coordinate, \
943 so commit {} should not be authorized.",
944 state_event.id, unique_commit
945 )),
946 Err(e) => Err(format!("Git push error: {}", e)),
947 }
948 })
949 .await
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use crate::AuditConfig;
957
958 #[tokio::test]
959 #[ignore] // Requires running relay
960 async fn test_grasp01_purgatory_against_relay() {
961 let relay_url = std::env::var("RELAY_URL").expect(
962 "RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081",
963 );
964
965 let config = AuditConfig::isolated();
966 let client = AuditClient::new(&relay_url, config)
967 .await
968 .unwrap_or_else(|_| {
969 panic!(
970 "Failed to connect to relay at {}. Ensure relay is running and accessible.",
971 relay_url
972 )
973 });
974
975 let results = PurgatoryTests::run_all(&client).await;
976 results.print_report();
977
978 assert!(
979 results.all_passed(),
980 "Some purgatory tests failed. See report above."
981 );
982 }
983}
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs
index c1003b9..73cbe1f 100644
--- a/grasp-audit/src/specs/grasp01/push_authorization.rs
+++ b/grasp-audit/src/specs/grasp01/push_authorization.rs
@@ -19,7 +19,7 @@
19/// Expected hash for PR test deterministic commit 19/// Expected hash for PR test deterministic commit
20/// 20///
21/// This hash is produced by creating a commit with: 21/// This hash is produced by creating a commit with:
22/// - File: test.txt containing "PR test deterministic commit" 22/// - File: test.txt containing "PR test deterministic commit\n" (with trailing newline)
23/// - Message: "PR test deterministic commit" 23/// - Message: "PR test deterministic commit"
24/// - Author: "GRASP Audit Test <test@grasp-audit.local>" 24/// - Author: "GRASP Audit Test <test@grasp-audit.local>"
25/// - Author date: 2024-01-01T00:00:00Z 25/// - Author date: 2024-01-01T00:00:00Z
@@ -29,8 +29,9 @@
29/// 29///
30/// Run `test_pr_test_commit_hash_discovery` to discover/verify this value. 30/// Run `test_pr_test_commit_hash_discovery` to discover/verify this value.
31#[allow(dead_code)] 31#[allow(dead_code)]
32const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb"; 32const PR_TEST_COMMIT_HASH: &str = "5a51b30e4615b572dcd5b9e487861b58605a5c21";
33 33
34use crate::specs::grasp01::SpecRef;
34use crate::{ 35use crate::{
35 clone_repo, create_commit, create_deterministic_commit_with_variant, try_push, try_push_to_ref, 36 clone_repo, create_commit, create_deterministic_commit_with_variant, try_push, try_push_to_ref,
36 AuditClient, CommitVariant, FixtureKind, TestContext, TestResult, 37 AuditClient, CommitVariant, FixtureKind, TestContext, TestResult,
@@ -207,7 +208,7 @@ async fn setup_pr_test_repo(
207) -> Result<(PathBuf, String, String, String), String> { 208) -> Result<(PathBuf, String, String, String), String> {
208 // Get fixtures 209 // Get fixtures
209 let repo_event = ctx 210 let repo_event = ctx
210 .get_fixture(FixtureKind::ValidRepo) 211 .get_fixture(FixtureKind::ValidRepoServed)
211 .await 212 .await
212 .map_err(|e| format!("Failed to get repo announcement: {}", e))?; 213 .map_err(|e| format!("Failed to get repo announcement: {}", e))?;
213 214
@@ -406,12 +407,12 @@ impl PushAuthorizationTests {
406 let ctx = TestContext::new(client); 407 let ctx = TestContext::new(client);
407 408
408 // Create repository (no state event) 409 // Create repository (no state event)
409 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 410 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
410 Ok(r) => r, 411 Ok(r) => r,
411 Err(e) => { 412 Err(e) => {
412 return TestResult::new( 413 return TestResult::new(
413 test_name, 414 test_name,
414 "GRASP-01:git-http:36", 415 SpecRef::GitAcceptPushesAlignState,
415 "Push rejected without state event", 416 "Push rejected without state event",
416 ) 417 )
417 .fail(format!("Failed to create repo: {}", e)) 418 .fail(format!("Failed to create repo: {}", e))
@@ -435,7 +436,7 @@ impl PushAuthorizationTests {
435 Err(e) => { 436 Err(e) => {
436 return TestResult::new( 437 return TestResult::new(
437 test_name, 438 test_name,
438 "GRASP-01:git-http:36", 439 SpecRef::GitAcceptPushesAlignState,
439 "Push rejected without state event", 440 "Push rejected without state event",
440 ) 441 )
441 .fail(&e) 442 .fail(&e)
@@ -449,7 +450,7 @@ impl PushAuthorizationTests {
449 cleanup(); 450 cleanup();
450 return TestResult::new( 451 return TestResult::new(
451 test_name, 452 test_name,
452 "GRASP-01:git-http:36", 453 SpecRef::GitAcceptPushesAlignState,
453 "Push rejected without state event", 454 "Push rejected without state event",
454 ) 455 )
455 .fail(&e); 456 .fail(&e);
@@ -462,19 +463,19 @@ impl PushAuthorizationTests {
462 match push_result { 463 match push_result {
463 Ok(false) => TestResult::new( 464 Ok(false) => TestResult::new(
464 test_name, 465 test_name,
465 "GRASP-01:git-http:36", 466 SpecRef::GitAcceptPushesAlignState,
466 "Push rejected without state event", 467 "Push rejected without state event",
467 ) 468 )
468 .pass(), 469 .pass(),
469 Ok(true) => TestResult::new( 470 Ok(true) => TestResult::new(
470 test_name, 471 test_name,
471 "GRASP-01:git-http:36", 472 SpecRef::GitAcceptPushesAlignState,
472 "Push rejected without state event", 473 "Push rejected without state event",
473 ) 474 )
474 .fail("Push accepted but should be rejected"), 475 .fail("Push accepted but should be rejected"),
475 Err(e) => TestResult::new( 476 Err(e) => TestResult::new(
476 test_name, 477 test_name,
477 "GRASP-01:git-http:36", 478 SpecRef::GitAcceptPushesAlignState,
478 "Push rejected without state event", 479 "Push rejected without state event",
479 ) 480 )
480 .fail(&e), 481 .fail(&e),
@@ -507,13 +508,13 @@ impl PushAuthorizationTests {
507 match ctx.get_fixture(FixtureKind::OwnerStateDataPushed).await { 508 match ctx.get_fixture(FixtureKind::OwnerStateDataPushed).await {
508 Ok(_state_event) => TestResult::new( 509 Ok(_state_event) => TestResult::new(
509 test_name, 510 test_name,
510 "GRASP-01:git-http:36", // TODO do we add purgatory line here? 511 SpecRef::GitAcceptPushesAlignState,
511 "Push authorized with matching state", 512 "Push authorized with matching state",
512 ) 513 )
513 .pass(), 514 .pass(),
514 Err(e) => TestResult::new( 515 Err(e) => TestResult::new(
515 test_name, 516 test_name,
516 "GRASP-01:git-http:36", 517 SpecRef::GitAcceptPushesAlignState,
517 "Push authorized with matching state", 518 "Push authorized with matching state",
518 ) 519 )
519 .fail(format!("{}", e)), 520 .fail(format!("{}", e)),
@@ -555,7 +556,7 @@ impl PushAuthorizationTests {
555 Err(e) => { 556 Err(e) => {
556 return TestResult::new( 557 return TestResult::new(
557 test_name, 558 test_name,
558 "GRASP-01:git-http:36", 559 SpecRef::GitAcceptPushesAlignState,
559 "Push rejected when commit not in state event", 560 "Push rejected when commit not in state event",
560 ) 561 )
561 .fail(format!("Failed to create RepoState fixture: {}", e)); 562 .fail(format!("Failed to create RepoState fixture: {}", e));
@@ -575,7 +576,7 @@ impl PushAuthorizationTests {
575 None => { 576 None => {
576 return TestResult::new( 577 return TestResult::new(
577 test_name, 578 test_name,
578 "GRASP-01:git-http:36", 579 SpecRef::GitAcceptPushesAlignState,
579 "Push rejected when commit not in state event", 580 "Push rejected when commit not in state event",
580 ) 581 )
581 .fail("Missing repo_id in state event"); 582 .fail("Missing repo_id in state event");
@@ -587,7 +588,7 @@ impl PushAuthorizationTests {
587 Err(e) => { 588 Err(e) => {
588 return TestResult::new( 589 return TestResult::new(
589 test_name, 590 test_name,
590 "GRASP-01:git-http:36", 591 SpecRef::GitAcceptPushesAlignState,
591 "Push rejected when commit not in state event", 592 "Push rejected when commit not in state event",
592 ) 593 )
593 .fail(format!("Failed to convert pubkey to bech32: {}", e)); 594 .fail(format!("Failed to convert pubkey to bech32: {}", e));
@@ -603,7 +604,7 @@ impl PushAuthorizationTests {
603 Err(e) => { 604 Err(e) => {
604 return TestResult::new( 605 return TestResult::new(
605 test_name, 606 test_name,
606 "GRASP-01:git-http:36", 607 SpecRef::GitAcceptPushesAlignState,
607 "Push rejected when commit not in state event", 608 "Push rejected when commit not in state event",
608 ) 609 )
609 .fail(format!("Failed to clone repo: {}", e)); 610 .fail(format!("Failed to clone repo: {}", e));
@@ -626,7 +627,7 @@ impl PushAuthorizationTests {
626 cleanup(); 627 cleanup();
627 return TestResult::new( 628 return TestResult::new(
628 test_name, 629 test_name,
629 "GRASP-01:git-http:36", 630 SpecRef::GitAcceptPushesAlignState,
630 "Push rejected when commit not in state event", 631 "Push rejected when commit not in state event",
631 ) 632 )
632 .fail(format!("Failed to create/checkout main branch: {}", e)); 633 .fail(format!("Failed to create/checkout main branch: {}", e));
@@ -635,7 +636,7 @@ impl PushAuthorizationTests {
635 cleanup(); 636 cleanup();
636 return TestResult::new( 637 return TestResult::new(
637 test_name, 638 test_name,
638 "GRASP-01:git-http:36", 639 SpecRef::GitAcceptPushesAlignState,
639 "Push rejected when commit not in state event", 640 "Push rejected when commit not in state event",
640 ) 641 )
641 .fail(format!( 642 .fail(format!(
@@ -652,7 +653,7 @@ impl PushAuthorizationTests {
652 cleanup(); 653 cleanup();
653 return TestResult::new( 654 return TestResult::new(
654 test_name, 655 test_name,
655 "GRASP-01:git-http:36", 656 SpecRef::GitAcceptPushesAlignState,
656 "Push rejected when commit not in state event", 657 "Push rejected when commit not in state event",
657 ) 658 )
658 .fail(format!("Failed to create wrong commit: {}", e)); 659 .fail(format!("Failed to create wrong commit: {}", e));
@@ -666,10 +667,10 @@ impl PushAuthorizationTests {
666 cleanup(); 667 cleanup();
667 668
668 match push_result { 669 match push_result {
669 Ok(false) => TestResult::new(test_name, "GRASP-01:git-http:36", "Push rejected when commit not in state event").pass(), 670 Ok(false) => TestResult::new(test_name, SpecRef::GitAcceptPushesAlignState, "Push rejected when commit not in state event").pass(),
670 Ok(true) => TestResult::new(test_name, "GRASP-01:git-http:36", "Push rejected when commit not in state event") 671 Ok(true) => TestResult::new(test_name, SpecRef::GitAcceptPushesAlignState, "Push rejected when commit not in state event")
671 .fail("Push accepted but should be rejected. The pushed commit is not in the state event."), 672 .fail("Push accepted but should be rejected. The pushed commit is not in the state event."),
672 Err(e) => TestResult::new(test_name, "GRASP-01:git-http:36", "Push rejected when commit not in state event").fail(&e), 673 Err(e) => TestResult::new(test_name, SpecRef::GitAcceptPushesAlignState, "Push rejected when commit not in state event").fail(&e),
673 } 674 }
674 } 675 }
675 676
@@ -704,13 +705,13 @@ impl PushAuthorizationTests {
704 { 705 {
705 Ok(_maintainer_state_event) => TestResult::new( 706 Ok(_maintainer_state_event) => TestResult::new(
706 test_name, 707 test_name,
707 "GRASP-01:git-http:36", 708 SpecRef::GitAcceptPushesAlignState,
708 "Push authorized by maintainer state event only (no announcement)", 709 "Push authorized by maintainer state event only (no announcement)",
709 ) 710 )
710 .pass(), 711 .pass(),
711 Err(e) => TestResult::new( 712 Err(e) => TestResult::new(
712 test_name, 713 test_name,
713 "GRASP-01:git-http:36", 714 SpecRef::GitAcceptPushesAlignState,
714 "Push authorized by maintainer state event only (no announcement)", 715 "Push authorized by maintainer state event only (no announcement)",
715 ) 716 )
716 .fail(format!("{}", e)), 717 .fail(format!("{}", e)),
@@ -747,13 +748,13 @@ impl PushAuthorizationTests {
747 { 748 {
748 Ok(_recursive_maintainer_state_event) => TestResult::new( 749 Ok(_recursive_maintainer_state_event) => TestResult::new(
749 test_name, 750 test_name,
750 "GRASP-01:git-http:36", 751 SpecRef::GitAcceptPushesAlignState,
751 "Push authorized by recursive maintainer state event", 752 "Push authorized by recursive maintainer state event",
752 ) 753 )
753 .pass(), 754 .pass(),
754 Err(e) => TestResult::new( 755 Err(e) => TestResult::new(
755 test_name, 756 test_name,
756 "GRASP-01:git-http:36", 757 SpecRef::GitAcceptPushesAlignState,
757 "Push authorized by recursive maintainer state event", 758 "Push authorized by recursive maintainer state event",
758 ) 759 )
759 .fail(format!("{}", e)), 760 .fail(format!("{}", e)),
@@ -797,7 +798,7 @@ impl PushAuthorizationTests {
797 Err(e) => { 798 Err(e) => {
798 return TestResult::new( 799 return TestResult::new(
799 test_name, 800 test_name,
800 "GRASP-01:git-http:36", 801 SpecRef::GitAcceptPushesAlignState,
801 "Non-maintainer state events ignored", 802 "Non-maintainer state events ignored",
802 ) 803 )
803 .fail(format!("Failed to get OwnerStateDataPushed fixture: {}", e)); 804 .fail(format!("Failed to get OwnerStateDataPushed fixture: {}", e));
@@ -815,7 +816,7 @@ impl PushAuthorizationTests {
815 None => { 816 None => {
816 return TestResult::new( 817 return TestResult::new(
817 test_name, 818 test_name,
818 "GRASP-01:git-http:36", 819 SpecRef::GitAcceptPushesAlignState,
819 "Non-maintainer state events ignored", 820 "Non-maintainer state events ignored",
820 ) 821 )
821 .fail("Missing repo_id in state event"); 822 .fail("Missing repo_id in state event");
@@ -827,7 +828,7 @@ impl PushAuthorizationTests {
827 Err(e) => { 828 Err(e) => {
828 return TestResult::new( 829 return TestResult::new(
829 test_name, 830 test_name,
830 "GRASP-01:git-http:36", 831 SpecRef::GitAcceptPushesAlignState,
831 "Non-maintainer state events ignored", 832 "Non-maintainer state events ignored",
832 ) 833 )
833 .fail(format!("Failed to convert pubkey to bech32: {}", e)); 834 .fail(format!("Failed to convert pubkey to bech32: {}", e));
@@ -842,7 +843,7 @@ impl PushAuthorizationTests {
842 Err(e) => { 843 Err(e) => {
843 return TestResult::new( 844 return TestResult::new(
844 test_name, 845 test_name,
845 "GRASP-01:git-http:36", 846 SpecRef::GitAcceptPushesAlignState,
846 "Non-maintainer state events ignored", 847 "Non-maintainer state events ignored",
847 ) 848 )
848 .fail(format!("Failed to clone repo: {}", e)); 849 .fail(format!("Failed to clone repo: {}", e));
@@ -864,7 +865,7 @@ impl PushAuthorizationTests {
864 cleanup(); 865 cleanup();
865 return TestResult::new( 866 return TestResult::new(
866 test_name, 867 test_name,
867 "GRASP-01:git-http:36", 868 SpecRef::GitAcceptPushesAlignState,
868 "Non-maintainer state events ignored", 869 "Non-maintainer state events ignored",
869 ) 870 )
870 .fail(format!("Failed to create commit: {}", e)); 871 .fail(format!("Failed to create commit: {}", e));
@@ -890,7 +891,7 @@ impl PushAuthorizationTests {
890 cleanup(); 891 cleanup();
891 return TestResult::new( 892 return TestResult::new(
892 test_name, 893 test_name,
893 "GRASP-01:git-http:36", 894 SpecRef::GitAcceptPushesAlignState,
894 "Non-maintainer state events ignored", 895 "Non-maintainer state events ignored",
895 ) 896 )
896 .fail(format!("Failed to build rogue state event: {}", e)); 897 .fail(format!("Failed to build rogue state event: {}", e));
@@ -902,7 +903,7 @@ impl PushAuthorizationTests {
902 cleanup(); 903 cleanup();
903 return TestResult::new( 904 return TestResult::new(
904 test_name, 905 test_name,
905 "GRASP-01:git-http:36", 906 SpecRef::GitAcceptPushesAlignState,
906 "Non-maintainer state events ignored", 907 "Non-maintainer state events ignored",
907 ) 908 )
908 .fail(format!("Failed to send rogue state event: {}", e)); 909 .fail(format!("Failed to send rogue state event: {}", e));
@@ -919,8 +920,8 @@ impl PushAuthorizationTests {
919 cleanup(); 920 cleanup();
920 921
921 match push_result { 922 match push_result {
922 Ok(false) => TestResult::new(test_name, "GRASP-01:git-http:36", "Non-maintainer state events ignored").pass(), 923 Ok(false) => TestResult::new(test_name, SpecRef::GitAcceptPushesAlignState, "Non-maintainer state events ignored").pass(),
923 Ok(true) => TestResult::new(test_name, "GRASP-01:git-http:36", "Non-maintainer state events ignored") 924 Ok(true) => TestResult::new(test_name, SpecRef::GitAcceptPushesAlignState, "Non-maintainer state events ignored")
924 .fail(format!( 925 .fail(format!(
925 "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ 926 "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \
926 a state event announcing commit {}, but the push was accepted. The relay should \ 927 a state event announcing commit {}, but the push was accepted. The relay should \
@@ -929,7 +930,7 @@ impl PushAuthorizationTests {
929 new_commit, 930 new_commit,
930 client.public_key() 931 client.public_key()
931 )), 932 )),
932 Err(e) => TestResult::new(test_name, "GRASP-01:git-http:36", "Non-maintainer state events ignored").fail(&e), 933 Err(e) => TestResult::new(test_name, SpecRef::GitAcceptPushesAlignState, "Non-maintainer state events ignored").fail(&e),
933 } 934 }
934 } 935 }
935 936
@@ -955,12 +956,12 @@ impl PushAuthorizationTests {
955 // ============================================================ 956 // ============================================================
956 let ctx = TestContext::new(client); 957 let ctx = TestContext::new(client);
957 958
958 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 959 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
959 Ok(r) => r, 960 Ok(r) => r,
960 Err(e) => { 961 Err(e) => {
961 return TestResult::new( 962 return TestResult::new(
962 test_name, 963 test_name,
963 "GRASP-01:git-http:40", 964 SpecRef::GitAcceptRefsNostrEventId,
964 "Push to refs/nostr/<invalid-event-id> rejected", 965 "Push to refs/nostr/<invalid-event-id> rejected",
965 ) 966 )
966 .fail(format!("Failed to create repo: {}", e)); 967 .fail(format!("Failed to create repo: {}", e));
@@ -986,7 +987,7 @@ impl PushAuthorizationTests {
986 Err(e) => { 987 Err(e) => {
987 return TestResult::new( 988 return TestResult::new(
988 test_name, 989 test_name,
989 "GRASP-01:git-http:40", 990 SpecRef::GitAcceptRefsNostrEventId,
990 "Push to refs/nostr/<invalid-event-id> rejected", 991 "Push to refs/nostr/<invalid-event-id> rejected",
991 ) 992 )
992 .fail(&e); 993 .fail(&e);
@@ -1001,7 +1002,7 @@ impl PushAuthorizationTests {
1001 cleanup(); 1002 cleanup();
1002 return TestResult::new( 1003 return TestResult::new(
1003 test_name, 1004 test_name,
1004 "GRASP-01:git-http:40", 1005 SpecRef::GitAcceptRefsNostrEventId,
1005 "Push to refs/nostr/<invalid-event-id> rejected", 1006 "Push to refs/nostr/<invalid-event-id> rejected",
1006 ) 1007 )
1007 .fail(&e); 1008 .fail(&e);
@@ -1020,13 +1021,13 @@ impl PushAuthorizationTests {
1020 match push_result { 1021 match push_result {
1021 Ok(false) => TestResult::new( 1022 Ok(false) => TestResult::new(
1022 test_name, 1023 test_name,
1023 "GRASP-01:git-http:40", 1024 SpecRef::GitAcceptRefsNostrEventId,
1024 "Push to refs/nostr/<invalid-event-id> rejected", 1025 "Push to refs/nostr/<invalid-event-id> rejected",
1025 ) 1026 )
1026 .pass(), 1027 .pass(),
1027 Ok(true) => TestResult::new( 1028 Ok(true) => TestResult::new(
1028 test_name, 1029 test_name,
1029 "GRASP-01:git-http:40", 1030 SpecRef::GitAcceptRefsNostrEventId,
1030 "Push to refs/nostr/<invalid-event-id> rejected", 1031 "Push to refs/nostr/<invalid-event-id> rejected",
1031 ) 1032 )
1032 .fail(format!( 1033 .fail(format!(
@@ -1037,7 +1038,7 @@ impl PushAuthorizationTests {
1037 )), 1038 )),
1038 Err(e) => TestResult::new( 1039 Err(e) => TestResult::new(
1039 test_name, 1040 test_name,
1040 "GRASP-01:git-http:40", 1041 SpecRef::GitAcceptRefsNostrEventId,
1041 "Push to refs/nostr/<invalid-event-id> rejected", 1042 "Push to refs/nostr/<invalid-event-id> rejected",
1042 ) 1043 )
1043 .fail(format!("Push error: {}", e)), 1044 .fail(format!("Push error: {}", e)),
@@ -1071,10 +1072,11 @@ impl PushAuthorizationTests {
1071 .get_fixture(FixtureKind::PRWrongCommitPushedBeforeEvent) 1072 .get_fixture(FixtureKind::PRWrongCommitPushedBeforeEvent)
1072 .await 1073 .await
1073 { 1074 {
1074 Ok(_pr_event) => TestResult::new(test_name, "GRASP-01:git-http:40", desc).pass(), 1075 Ok(_pr_event) => {
1075 Err(e) => { 1076 TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).pass()
1076 TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(format!("{}", e))
1077 } 1077 }
1078 Err(e) => TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1079 .fail(format!("{}", e)),
1078 } 1080 }
1079 } 1081 }
1080 1082
@@ -1100,7 +1102,7 @@ impl PushAuthorizationTests {
1100 { 1102 {
1101 Ok(e) => e, 1103 Ok(e) => e,
1102 Err(e) => { 1104 Err(e) => {
1103 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1105 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1104 .fail(format!("{}", e)); 1106 .fail(format!("{}", e));
1105 } 1107 }
1106 }; 1108 };
@@ -1108,10 +1110,10 @@ impl PushAuthorizationTests {
1108 let pr_event_id = pr_event.id.to_hex(); 1110 let pr_event_id = pr_event.id.to_hex();
1109 1111
1110 // Get repo info for cloning (fresh clone for verification) 1112 // Get repo info for cloning (fresh clone for verification)
1111 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 1113 let repo = match ctx.get_fixture(FixtureKind::ValidRepoServed).await {
1112 Ok(r) => r, 1114 Ok(r) => r,
1113 Err(e) => { 1115 Err(e) => {
1114 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1116 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1115 .fail(format!("{}", e)); 1117 .fail(format!("{}", e));
1116 } 1118 }
1117 }; 1119 };
@@ -1127,7 +1129,7 @@ impl PushAuthorizationTests {
1127 let owner_npub = match repo.pubkey.to_bech32() { 1129 let owner_npub = match repo.pubkey.to_bech32() {
1128 Ok(n) => n, 1130 Ok(n) => n,
1129 Err(e) => { 1131 Err(e) => {
1130 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1132 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1131 .fail(format!("Failed to get owner npub: {}", e)); 1133 .fail(format!("Failed to get owner npub: {}", e));
1132 } 1134 }
1133 }; 1135 };
@@ -1136,7 +1138,8 @@ impl PushAuthorizationTests {
1136 let clone_path = match clone_repo(relay_domain, &owner_npub, &repo_id) { 1138 let clone_path = match clone_repo(relay_domain, &owner_npub, &repo_id) {
1137 Ok(p) => p, 1139 Ok(p) => p,
1138 Err(e) => { 1140 Err(e) => {
1139 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1141 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1142 .fail(&e);
1140 } 1143 }
1141 }; 1144 };
1142 1145
@@ -1146,7 +1149,8 @@ impl PushAuthorizationTests {
1146 Ok(exists) => exists, 1149 Ok(exists) => exists,
1147 Err(e) => { 1150 Err(e) => {
1148 let _ = fs::remove_dir_all(&clone_path); 1151 let _ = fs::remove_dir_all(&clone_path);
1149 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1152 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1153 .fail(&e);
1150 } 1154 }
1151 }; 1155 };
1152 1156
@@ -1154,13 +1158,13 @@ impl PushAuthorizationTests {
1154 1158
1155 // Ref should be deleted since the pushed commit doesn't match the PR event's `c` tag 1159 // Ref should be deleted since the pushed commit doesn't match the PR event's `c` tag
1156 if refs_exist { 1160 if refs_exist {
1157 TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(format!( 1161 TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).fail(format!(
1158 "Expected refs/nostr/{} to be deleted when PR event published with non-matching commit, \ 1162 "Expected refs/nostr/{} to be deleted when PR event published with non-matching commit, \
1159 but the ref still exists. The relay should delete refs that don't match the event's `c` tag.", 1163 but the ref still exists. The relay should delete refs that don't match the event's `c` tag.",
1160 pr_event_id 1164 pr_event_id
1161 )) 1165 ))
1162 } else { 1166 } else {
1163 TestResult::new(test_name, "GRASP-01:git-http:40", desc).pass() 1167 TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).pass()
1164 } 1168 }
1165 } 1169 }
1166 1170
@@ -1186,7 +1190,7 @@ impl PushAuthorizationTests {
1186 { 1190 {
1187 Ok(e) => e, 1191 Ok(e) => e,
1188 Err(e) => { 1192 Err(e) => {
1189 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1193 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1190 .fail(format!("{}", e)); 1194 .fail(format!("{}", e));
1191 } 1195 }
1192 }; 1196 };
@@ -1194,10 +1198,10 @@ impl PushAuthorizationTests {
1194 let pr_event_id = pr_event.id.to_hex(); 1198 let pr_event_id = pr_event.id.to_hex();
1195 1199
1196 // Get repo info for cloning (fresh clone for this test) 1200 // Get repo info for cloning (fresh clone for this test)
1197 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 1201 let repo = match ctx.get_fixture(FixtureKind::ValidRepoServed).await {
1198 Ok(r) => r, 1202 Ok(r) => r,
1199 Err(e) => { 1203 Err(e) => {
1200 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1204 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1201 .fail(format!("{}", e)); 1205 .fail(format!("{}", e));
1202 } 1206 }
1203 }; 1207 };
@@ -1213,7 +1217,7 @@ impl PushAuthorizationTests {
1213 let owner_npub = match repo.pubkey.to_bech32() { 1217 let owner_npub = match repo.pubkey.to_bech32() {
1214 Ok(n) => n, 1218 Ok(n) => n,
1215 Err(e) => { 1219 Err(e) => {
1216 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1220 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1217 .fail(format!("Failed to get owner npub: {}", e)); 1221 .fail(format!("Failed to get owner npub: {}", e));
1218 } 1222 }
1219 }; 1223 };
@@ -1222,15 +1226,16 @@ impl PushAuthorizationTests {
1222 let clone_path = match clone_repo(relay_domain, &owner_npub, &repo_id) { 1226 let clone_path = match clone_repo(relay_domain, &owner_npub, &repo_id) {
1223 Ok(p) => p, 1227 Ok(p) => p,
1224 Err(e) => { 1228 Err(e) => {
1225 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1229 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1230 .fail(&e);
1226 } 1231 }
1227 }; 1232 };
1228 1233
1229 // Create a wrong commit (Owner variant, not PRTestCommit) 1234 // Create a wrong commit (unique, not PRTestCommit) - use create_commit so it always
1230 if let Err(e) = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner) 1235 // succeeds even when the clone already has the Owner deterministic content on disk.
1231 { 1236 if let Err(e) = create_commit(&clone_path, "wrong commit - not the PR test commit") {
1232 let _ = fs::remove_dir_all(&clone_path); 1237 let _ = fs::remove_dir_all(&clone_path);
1233 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1238 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).fail(&e);
1234 } 1239 }
1235 1240
1236 // Try to push with wrong commit (should be rejected since PR event exists) 1241 // Try to push with wrong commit (should be rejected since PR event exists)
@@ -1238,7 +1243,8 @@ impl PushAuthorizationTests {
1238 Ok(success) => success, 1243 Ok(success) => success,
1239 Err(e) => { 1244 Err(e) => {
1240 let _ = fs::remove_dir_all(&clone_path); 1245 let _ = fs::remove_dir_all(&clone_path);
1241 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1246 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1247 .fail(&e);
1242 } 1248 }
1243 }; 1249 };
1244 1250
@@ -1246,11 +1252,11 @@ impl PushAuthorizationTests {
1246 1252
1247 // Should REJECT - PR event exists with different commit hash 1253 // Should REJECT - PR event exists with different commit hash
1248 if push_succeeded { 1254 if push_succeeded {
1249 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1255 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1250 .fail("Push accepted (expected rejection due to commit hash mismatch)"); 1256 .fail("Push accepted (expected rejection due to commit hash mismatch)");
1251 } 1257 }
1252 1258
1253 TestResult::new(test_name, "GRASP-01:git-http:40", desc).pass() 1259 TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).pass()
1254 } 1260 }
1255 1261
1256 /// Test 4: Push correct commit to refs/nostr/<pr-event-id> AFTER PR event exists 1262 /// Test 4: Push correct commit to refs/nostr/<pr-event-id> AFTER PR event exists
@@ -1275,7 +1281,7 @@ impl PushAuthorizationTests {
1275 { 1281 {
1276 Ok(e) => e, 1282 Ok(e) => e,
1277 Err(e) => { 1283 Err(e) => {
1278 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1284 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1279 .fail(format!("{}", e)); 1285 .fail(format!("{}", e));
1280 } 1286 }
1281 }; 1287 };
@@ -1283,10 +1289,10 @@ impl PushAuthorizationTests {
1283 let pr_event_id = pr_event.id.to_hex(); 1289 let pr_event_id = pr_event.id.to_hex();
1284 1290
1285 // Get repo info for cloning (fresh clone for this test) 1291 // Get repo info for cloning (fresh clone for this test)
1286 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 1292 let repo = match ctx.get_fixture(FixtureKind::ValidRepoServed).await {
1287 Ok(r) => r, 1293 Ok(r) => r,
1288 Err(e) => { 1294 Err(e) => {
1289 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1295 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1290 .fail(format!("{}", e)); 1296 .fail(format!("{}", e));
1291 } 1297 }
1292 }; 1298 };
@@ -1302,7 +1308,7 @@ impl PushAuthorizationTests {
1302 let owner_npub = match repo.pubkey.to_bech32() { 1308 let owner_npub = match repo.pubkey.to_bech32() {
1303 Ok(n) => n, 1309 Ok(n) => n,
1304 Err(e) => { 1310 Err(e) => {
1305 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1311 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1306 .fail(format!("Failed to get owner npub: {}", e)); 1312 .fail(format!("Failed to get owner npub: {}", e));
1307 } 1313 }
1308 }; 1314 };
@@ -1311,26 +1317,27 @@ impl PushAuthorizationTests {
1311 let clone_path = match clone_repo(relay_domain, &owner_npub, &repo_id) { 1317 let clone_path = match clone_repo(relay_domain, &owner_npub, &repo_id) {
1312 Ok(p) => p, 1318 Ok(p) => p,
1313 Err(e) => { 1319 Err(e) => {
1314 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1320 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1321 .fail(&e);
1315 } 1322 }
1316 }; 1323 };
1317 1324
1318 // Create the CORRECT PR test commit (the one expected by PR event) 1325 // Create the CORRECT PR test commit (the one expected by PR event)
1319 if let Err(e) = reset_to_correct_pr_commit(&clone_path) { 1326 if let Err(e) = reset_to_correct_pr_commit(&clone_path) {
1320 let _ = fs::remove_dir_all(&clone_path); 1327 let _ = fs::remove_dir_all(&clone_path);
1321 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1328 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).fail(&e);
1322 } 1329 }
1323 1330
1324 // Check event is not yet served by relay (still in purgatory) 1331 // Check event is not yet served by relay (still in purgatory)
1325 match client.is_event_on_relay(pr_event.id).await { 1332 match client.is_event_on_relay(pr_event.id).await {
1326 Ok(on_relay) => { 1333 Ok(on_relay) => {
1327 if on_relay { 1334 if on_relay {
1328 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1335 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1329 .fail("PR event not in purgatory before correct commit pushed to refs/nostr/<event-id> (the relay serve the PR event)"); 1336 .fail("PR event not in purgatory before correct commit pushed to refs/nostr/<event-id> (the relay serve the PR event)");
1330 } 1337 }
1331 } 1338 }
1332 Err(_) => { 1339 Err(_) => {
1333 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1340 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1334 .fail("failed to query relay"); 1341 .fail("failed to query relay");
1335 } 1342 }
1336 } 1343 }
@@ -1340,7 +1347,8 @@ impl PushAuthorizationTests {
1340 Ok(success) => success, 1347 Ok(success) => success,
1341 Err(e) => { 1348 Err(e) => {
1342 let _ = fs::remove_dir_all(&clone_path); 1349 let _ = fs::remove_dir_all(&clone_path);
1343 return TestResult::new(test_name, "GRASP-01:git-http:40", desc).fail(&e); 1350 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1351 .fail(&e);
1344 } 1352 }
1345 }; 1353 };
1346 1354
@@ -1348,7 +1356,7 @@ impl PushAuthorizationTests {
1348 1356
1349 // Should ACCEPT - commit matches PR event's c tag 1357 // Should ACCEPT - commit matches PR event's c tag
1350 if !push_succeeded { 1358 if !push_succeeded {
1351 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1359 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1352 .fail("Push rejected (expected acceptance since commit matches PR event)"); 1360 .fail("Push rejected (expected acceptance since commit matches PR event)");
1353 } 1361 }
1354 1362
@@ -1361,17 +1369,17 @@ impl PushAuthorizationTests {
1361 match client.is_event_on_relay(pr_event.id).await { 1369 match client.is_event_on_relay(pr_event.id).await {
1362 Ok(on_relay) => { 1370 Ok(on_relay) => {
1363 if !on_relay { 1371 if !on_relay {
1364 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1372 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1365 .fail("PR event not served after correct commit at refs/nostr/<event-id>"); 1373 .fail("PR event not served after correct commit at refs/nostr/<event-id>");
1366 } 1374 }
1367 } 1375 }
1368 Err(_) => { 1376 Err(_) => {
1369 return TestResult::new(test_name, "GRASP-01:git-http:40", desc) 1377 return TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc)
1370 .fail("failed to query relay"); 1378 .fail("failed to query relay");
1371 } 1379 }
1372 } 1380 }
1373 1381
1374 TestResult::new(test_name, "GRASP-01:git-http:40", desc).pass() 1382 TestResult::new(test_name, SpecRef::GitAcceptRefsNostrEventId, desc).pass()
1375 } 1383 }
1376 1384
1377 /// Test that HEAD is set after a state event is published with an existing commit 1385 /// Test that HEAD is set after a state event is published with an existing commit
@@ -1408,20 +1416,19 @@ impl PushAuthorizationTests {
1408 { 1416 {
1409 Ok(e) => e, 1417 Ok(e) => e,
1410 Err(e) => { 1418 Err(e) => {
1411 return TestResult::new(test_name, "GRASP-01:git-http:38", desc).fail(format!( 1419 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc).fail(
1412 "Failed to create HeadSetToDevelopBranch fixture: {}", 1420 format!("Failed to create HeadSetToDevelopBranch fixture: {}", e),
1413 e 1421 );
1414 ));
1415 } 1422 }
1416 }; 1423 };
1417 1424
1418 // ============================================================ 1425 // ============================================================
1419 // Step 2: Extract repo_id and owner npub from ValidRepo (cached by fixture) 1426 // Step 2: Extract repo_id and owner npub from ValidRepo (cached by fixture)
1420 // ============================================================ 1427 // ============================================================
1421 let valid_repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 1428 let valid_repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
1422 Ok(e) => e, 1429 Ok(e) => e,
1423 Err(e) => { 1430 Err(e) => {
1424 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1431 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1425 .fail(format!("Failed to get ValidRepo fixture: {}", e)); 1432 .fail(format!("Failed to get ValidRepo fixture: {}", e));
1426 } 1433 }
1427 }; 1434 };
@@ -1434,7 +1441,7 @@ impl PushAuthorizationTests {
1434 { 1441 {
1435 Some(id) => id.to_string(), 1442 Some(id) => id.to_string(),
1436 None => { 1443 None => {
1437 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1444 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1438 .fail("Missing repo_id in ValidRepo"); 1445 .fail("Missing repo_id in ValidRepo");
1439 } 1446 }
1440 }; 1447 };
@@ -1442,7 +1449,7 @@ impl PushAuthorizationTests {
1442 let npub = match valid_repo.pubkey.to_bech32() { 1449 let npub = match valid_repo.pubkey.to_bech32() {
1443 Ok(n) => n, 1450 Ok(n) => n,
1444 Err(e) => { 1451 Err(e) => {
1445 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1452 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1446 .fail(format!("Failed to convert pubkey to bech32: {}", e)); 1453 .fail(format!("Failed to convert pubkey to bech32: {}", e));
1447 } 1454 }
1448 }; 1455 };
@@ -1454,16 +1461,16 @@ impl PushAuthorizationTests {
1454 match get_default_branch_from_info_refs(relay_domain, &npub, &repo_id).await { 1461 match get_default_branch_from_info_refs(relay_domain, &npub, &repo_id).await {
1455 Ok(branch) => branch, 1462 Ok(branch) => branch,
1456 Err(e) => { 1463 Err(e) => {
1457 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1464 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1458 .fail(format!("Failed to get default branch: {}", e)); 1465 .fail(format!("Failed to get default branch: {}", e));
1459 } 1466 }
1460 }; 1467 };
1461 1468
1462 // Verify HEAD points to refs/heads/develop 1469 // Verify HEAD points to refs/heads/develop
1463 if default_branch == "refs/heads/develop" { 1470 if default_branch == "refs/heads/develop" {
1464 TestResult::new(test_name, "GRASP-01:git-http:38", desc).pass() 1471 TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc).pass()
1465 } else { 1472 } else {
1466 TestResult::new(test_name, "GRASP-01:git-http:38", desc).fail(format!( 1473 TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc).fail(format!(
1467 "Expected HEAD to point to 'refs/heads/develop' but got '{}'. \ 1474 "Expected HEAD to point to 'refs/heads/develop' but got '{}'. \
1468 GRASP-01 requires: 'MUST set repository HEAD per repository state announcement \ 1475 GRASP-01 requires: 'MUST set repository HEAD per repository state announcement \
1469 as soon as the git data related to that branch has been received.'", 1476 as soon as the git data related to that branch has been received.'",
@@ -1512,20 +1519,19 @@ impl PushAuthorizationTests {
1512 let _develop_state = match ctx.get_fixture(FixtureKind::HeadSetToDevelopBranch).await { 1519 let _develop_state = match ctx.get_fixture(FixtureKind::HeadSetToDevelopBranch).await {
1513 Ok(e) => e, 1520 Ok(e) => e,
1514 Err(e) => { 1521 Err(e) => {
1515 return TestResult::new(test_name, "GRASP-01:git-http:38", desc).fail(format!( 1522 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc).fail(
1516 "Failed to create HeadSetToDevelopBranch fixture: {}", 1523 format!("Failed to create HeadSetToDevelopBranch fixture: {}", e),
1517 e 1524 );
1518 ));
1519 } 1525 }
1520 }; 1526 };
1521 1527
1522 // ============================================================ 1528 // ============================================================
1523 // Step 2: Extract repo_id and owner npub from ValidRepo (cached by fixture) 1529 // Step 2: Extract repo_id and owner npub from ValidRepo (cached by fixture)
1524 // ============================================================ 1530 // ============================================================
1525 let valid_repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 1531 let valid_repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
1526 Ok(e) => e, 1532 Ok(e) => e,
1527 Err(e) => { 1533 Err(e) => {
1528 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1534 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1529 .fail(format!("Failed to get ValidRepo fixture: {}", e)); 1535 .fail(format!("Failed to get ValidRepo fixture: {}", e));
1530 } 1536 }
1531 }; 1537 };
@@ -1538,7 +1544,7 @@ impl PushAuthorizationTests {
1538 { 1544 {
1539 Some(id) => id.to_string(), 1545 Some(id) => id.to_string(),
1540 None => { 1546 None => {
1541 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1547 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1542 .fail("Missing repo_id in ValidRepo"); 1548 .fail("Missing repo_id in ValidRepo");
1543 } 1549 }
1544 }; 1550 };
@@ -1546,7 +1552,7 @@ impl PushAuthorizationTests {
1546 let npub = match valid_repo.pubkey.to_bech32() { 1552 let npub = match valid_repo.pubkey.to_bech32() {
1547 Ok(n) => n, 1553 Ok(n) => n,
1548 Err(e) => { 1554 Err(e) => {
1549 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1555 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1550 .fail(format!("Failed to convert pubkey to bech32: {}", e)); 1556 .fail(format!("Failed to convert pubkey to bech32: {}", e));
1551 } 1557 }
1552 }; 1558 };
@@ -1557,7 +1563,7 @@ impl PushAuthorizationTests {
1557 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { 1563 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
1558 Ok(path) => path, 1564 Ok(path) => path,
1559 Err(e) => { 1565 Err(e) => {
1560 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1566 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1561 .fail(format!("Failed to clone repo: {}", e)); 1567 .fail(format!("Failed to clone repo: {}", e));
1562 } 1568 }
1563 }; 1569 };
@@ -1572,7 +1578,7 @@ impl PushAuthorizationTests {
1572 1578
1573 if let Err(e) = output { 1579 if let Err(e) = output {
1574 let _ = fs::remove_dir_all(&clone_path); 1580 let _ = fs::remove_dir_all(&clone_path);
1575 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1581 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1576 .fail(format!("Failed to create develop1 branch: {}", e)); 1582 .fail(format!("Failed to create develop1 branch: {}", e));
1577 } 1583 }
1578 1584
@@ -1581,7 +1587,7 @@ impl PushAuthorizationTests {
1581 Ok(hash) => hash, 1587 Ok(hash) => hash,
1582 Err(e) => { 1588 Err(e) => {
1583 let _ = fs::remove_dir_all(&clone_path); 1589 let _ = fs::remove_dir_all(&clone_path);
1584 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1590 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1585 .fail(format!("Failed to create commit: {}", e)); 1591 .fail(format!("Failed to create commit: {}", e));
1586 } 1592 }
1587 }; 1593 };
@@ -1610,7 +1616,7 @@ impl PushAuthorizationTests {
1610 Ok(e) => e, 1616 Ok(e) => e,
1611 Err(e) => { 1617 Err(e) => {
1612 let _ = fs::remove_dir_all(&clone_path); 1618 let _ = fs::remove_dir_all(&clone_path);
1613 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1619 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1614 .fail(format!("Failed to build state event: {}", e)); 1620 .fail(format!("Failed to build state event: {}", e));
1615 } 1621 }
1616 }; 1622 };
@@ -1621,7 +1627,7 @@ impl PushAuthorizationTests {
1621 .await 1627 .await
1622 { 1628 {
1623 let _ = fs::remove_dir_all(&clone_path); 1629 let _ = fs::remove_dir_all(&clone_path);
1624 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1630 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1625 .fail(format!("Failed to send state event: {}", e)); 1631 .fail(format!("Failed to send state event: {}", e));
1626 } 1632 }
1627 1633
@@ -1634,11 +1640,11 @@ impl PushAuthorizationTests {
1634 match push_result { 1640 match push_result {
1635 Ok(true) => { /* Push succeeded, continue to verify */ } 1641 Ok(true) => { /* Push succeeded, continue to verify */ }
1636 Ok(false) => { 1642 Ok(false) => {
1637 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1643 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1638 .fail("Push to refs/heads/develop1 was rejected"); 1644 .fail("Push to refs/heads/develop1 was rejected");
1639 } 1645 }
1640 Err(e) => { 1646 Err(e) => {
1641 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1647 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1642 .fail(format!("Failed to push develop1 branch: {}", e)); 1648 .fail(format!("Failed to push develop1 branch: {}", e));
1643 } 1649 }
1644 } 1650 }
@@ -1651,16 +1657,16 @@ impl PushAuthorizationTests {
1651 match get_default_branch_from_info_refs(relay_domain, &npub, &repo_id).await { 1657 match get_default_branch_from_info_refs(relay_domain, &npub, &repo_id).await {
1652 Ok(branch) => branch, 1658 Ok(branch) => branch,
1653 Err(e) => { 1659 Err(e) => {
1654 return TestResult::new(test_name, "GRASP-01:git-http:38", desc) 1660 return TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc)
1655 .fail(format!("Failed to get default branch: {}", e)); 1661 .fail(format!("Failed to get default branch: {}", e));
1656 } 1662 }
1657 }; 1663 };
1658 1664
1659 // Verify HEAD points to refs/heads/develop1 1665 // Verify HEAD points to refs/heads/develop1
1660 if default_branch == "refs/heads/develop1" { 1666 if default_branch == "refs/heads/develop1" {
1661 TestResult::new(test_name, "GRASP-01:git-http:38", desc).pass() 1667 TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc).pass()
1662 } else { 1668 } else {
1663 TestResult::new(test_name, "GRASP-01:git-http:38", desc).fail(format!( 1669 TestResult::new(test_name, SpecRef::GitSetHeadOnReceive, desc).fail(format!(
1664 "Expected HEAD to point to 'refs/heads/develop1' but got '{}'. \ 1670 "Expected HEAD to point to 'refs/heads/develop1' but got '{}'. \
1665 GRASP-01 requires: 'MUST set repository HEAD per repository state announcement \ 1671 GRASP-01 requires: 'MUST set repository HEAD per repository state announcement \
1666 as soon as the git data related to that branch has been received.'", 1672 as soon as the git data related to that branch has been received.'",
@@ -1701,24 +1707,24 @@ mod tests {
1701 String::from_utf8_lossy(&output.stderr) 1707 String::from_utf8_lossy(&output.stderr)
1702 ); 1708 );
1703 1709
1704 // Configure git user - use PR Test Author identity 1710 // Configure git user - use same identity as clone_repo in fixtures.rs
1705 let output = Command::new("git") 1711 let output = Command::new("git")
1706 .args(["config", "user.email", "pr-test@example.com"]) 1712 .args(["config", "user.email", "test@grasp-audit.local"])
1707 .current_dir(path) 1713 .current_dir(path)
1708 .output() 1714 .output()
1709 .expect("git config email failed"); 1715 .expect("git config email failed");
1710 assert!(output.status.success(), "git config email failed"); 1716 assert!(output.status.success(), "git config email failed");
1711 1717
1712 let output = Command::new("git") 1718 let output = Command::new("git")
1713 .args(["config", "user.name", "PR Test Author"]) 1719 .args(["config", "user.name", "GRASP Audit Test"])
1714 .current_dir(path) 1720 .current_dir(path)
1715 .output() 1721 .output()
1716 .expect("git config name failed"); 1722 .expect("git config name failed");
1717 assert!(output.status.success(), "git config name failed"); 1723 assert!(output.status.success(), "git config name failed");
1718 1724
1719 // Create the deterministic file content 1725 // Create the deterministic file content (must match CommitVariant::PRTestCommit exactly)
1720 let test_file = path.join("test.txt"); 1726 let test_file = path.join("test.txt");
1721 fs::write(&test_file, "PR test deterministic commit").expect("Failed to write test file"); 1727 fs::write(&test_file, "PR test deterministic commit\n").expect("Failed to write test file");
1722 1728
1723 // Add the file 1729 // Add the file
1724 let output = Command::new("git") 1730 let output = Command::new("git")
diff --git a/grasp-audit/src/specs/grasp01/repository_creation.rs b/grasp-audit/src/specs/grasp01/repository_creation.rs
index 2eddb97..5730f1c 100644
--- a/grasp-audit/src/specs/grasp01/repository_creation.rs
+++ b/grasp-audit/src/specs/grasp01/repository_creation.rs
@@ -15,6 +15,7 @@
15//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test 15//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
16//! ``` 16//! ```
17 17
18use crate::specs::grasp01::SpecRef;
18use crate::{AuditClient, FixtureKind, TestContext, TestResult}; 19use crate::{AuditClient, FixtureKind, TestContext, TestResult};
19use nostr_sdk::prelude::*; 20use nostr_sdk::prelude::*;
20 21
@@ -50,12 +51,12 @@ impl RepositoryCreationTests {
50 let ctx = TestContext::new(client); 51 let ctx = TestContext::new(client);
51 52
52 // Use TestContext to create and send repository announcement 53 // Use TestContext to create and send repository announcement
53 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 54 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
54 Ok(r) => r, 55 Ok(r) => r,
55 Err(e) => { 56 Err(e) => {
56 return TestResult::new( 57 return TestResult::new(
57 test_name, 58 test_name,
58 "GRASP-01:git-http:34", 59 SpecRef::GitServeRepository,
59 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted", 60 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted",
60 ) 61 )
61 .fail(format!("Failed to create repo fixture: {}", e)) 62 .fail(format!("Failed to create repo fixture: {}", e))
@@ -76,7 +77,7 @@ impl RepositoryCreationTests {
76 None => { 77 None => {
77 return TestResult::new( 78 return TestResult::new(
78 test_name, 79 test_name,
79 "GRASP-01:git-http:34", 80 SpecRef::GitServeRepository,
80 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted", 81 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted",
81 ) 82 )
82 .fail("Repository announcement missing d tag") 83 .fail("Repository announcement missing d tag")
@@ -88,7 +89,7 @@ impl RepositoryCreationTests {
88 Err(e) => { 89 Err(e) => {
89 return TestResult::new( 90 return TestResult::new(
90 test_name, 91 test_name,
91 "GRASP-01:git-http:34", 92 SpecRef::GitServeRepository,
92 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted", 93 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted",
93 ) 94 )
94 .fail(format!("Failed to convert pubkey to npub: {}", e)) 95 .fail(format!("Failed to convert pubkey to npub: {}", e))
@@ -99,7 +100,7 @@ impl RepositoryCreationTests {
99 if let Err(e) = check_repo_accessible_via_http(relay_domain, &npub, &repo_id).await { 100 if let Err(e) = check_repo_accessible_via_http(relay_domain, &npub, &repo_id).await {
100 return TestResult::new( 101 return TestResult::new(
101 test_name, 102 test_name,
102 "GRASP-01:git-http:34", 103 SpecRef::GitServeRepository,
103 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted", 104 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted",
104 ) 105 )
105 .fail(format!("Repository not accessible via HTTP: {}", e)); 106 .fail(format!("Repository not accessible via HTTP: {}", e));
@@ -107,7 +108,7 @@ impl RepositoryCreationTests {
107 108
108 TestResult::new( 109 TestResult::new(
109 test_name, 110 test_name,
110 "GRASP-01:git-http:34", 111 SpecRef::GitServeRepository,
111 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted", 112 "Bare repository must be created and accessible via Smart HTTP when announcement is accepted",
112 ) 113 )
113 .pass() 114 .pass()
@@ -130,12 +131,12 @@ impl RepositoryCreationTests {
130 let ctx = TestContext::new(client); 131 let ctx = TestContext::new(client);
131 132
132 // Create a repository announcement 133 // Create a repository announcement
133 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 134 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
134 Ok(r) => r, 135 Ok(r) => r,
135 Err(e) => { 136 Err(e) => {
136 return TestResult::new( 137 return TestResult::new(
137 test_name, 138 test_name,
138 "GRASP-01:git-http:44", 139 SpecRef::GitServeWebpage,
139 "Relay SHOULD serve a webpage for existing repositories", 140 "Relay SHOULD serve a webpage for existing repositories",
140 ) 141 )
141 .fail(format!("Failed to create repo fixture: {}", e)) 142 .fail(format!("Failed to create repo fixture: {}", e))
@@ -156,7 +157,7 @@ impl RepositoryCreationTests {
156 None => { 157 None => {
157 return TestResult::new( 158 return TestResult::new(
158 test_name, 159 test_name,
159 "GRASP-01:git-http:44", 160 SpecRef::GitServeWebpage,
160 "Relay SHOULD serve a webpage for existing repositories", 161 "Relay SHOULD serve a webpage for existing repositories",
161 ) 162 )
162 .fail("Repository announcement missing d tag") 163 .fail("Repository announcement missing d tag")
@@ -168,7 +169,7 @@ impl RepositoryCreationTests {
168 Err(e) => { 169 Err(e) => {
169 return TestResult::new( 170 return TestResult::new(
170 test_name, 171 test_name,
171 "GRASP-01:git-http:44", 172 SpecRef::GitServeWebpage,
172 "Relay SHOULD serve a webpage for existing repositories", 173 "Relay SHOULD serve a webpage for existing repositories",
173 ) 174 )
174 .fail(format!("Failed to convert pubkey to npub: {}", e)) 175 .fail(format!("Failed to convert pubkey to npub: {}", e))
@@ -179,7 +180,7 @@ impl RepositoryCreationTests {
179 if let Err(e) = check_webpage_served(relay_domain, &npub, &repo_id).await { 180 if let Err(e) = check_webpage_served(relay_domain, &npub, &repo_id).await {
180 return TestResult::new( 181 return TestResult::new(
181 test_name, 182 test_name,
182 "GRASP-01:git-http:44", 183 SpecRef::GitServeWebpage,
183 "Relay SHOULD serve a webpage for existing repositories", 184 "Relay SHOULD serve a webpage for existing repositories",
184 ) 185 )
185 .fail(format!("Webpage not served: {}", e)); 186 .fail(format!("Webpage not served: {}", e));
@@ -187,7 +188,7 @@ impl RepositoryCreationTests {
187 188
188 TestResult::new( 189 TestResult::new(
189 test_name, 190 test_name,
190 "GRASP-01:git-http:44", 191 SpecRef::GitServeWebpage,
191 "Relay SHOULD serve a webpage for existing repositories", 192 "Relay SHOULD serve a webpage for existing repositories",
192 ) 193 )
193 .pass() 194 .pass()
@@ -209,12 +210,12 @@ impl RepositoryCreationTests {
209 210
210 let ctx = TestContext::new(client); 211 let ctx = TestContext::new(client);
211 212
212 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { 213 let repo = match ctx.get_fixture(FixtureKind::ValidRepoSent).await {
213 Ok(r) => r, 214 Ok(r) => r,
214 Err(e) => { 215 Err(e) => {
215 return TestResult::new( 216 return TestResult::new(
216 test_name, 217 test_name,
217 "GRASP-01:git-http:44", 218 SpecRef::GitServeWebpage,
218 "Relay SHOULD return 404 for repositories it doesn't host", 219 "Relay SHOULD return 404 for repositories it doesn't host",
219 ) 220 )
220 .fail(format!("Failed to create repo fixture: {}", e)) 221 .fail(format!("Failed to create repo fixture: {}", e))
@@ -226,7 +227,7 @@ impl RepositoryCreationTests {
226 Err(e) => { 227 Err(e) => {
227 return TestResult::new( 228 return TestResult::new(
228 test_name, 229 test_name,
229 "GRASP-01:git-http:44", 230 SpecRef::GitServeWebpage,
230 "Relay SHOULD return 404 for repositories it doesn't host", 231 "Relay SHOULD return 404 for repositories it doesn't host",
231 ) 232 )
232 .fail(format!("Failed to convert pubkey to npub: {}", e)) 233 .fail(format!("Failed to convert pubkey to npub: {}", e))
@@ -239,7 +240,7 @@ impl RepositoryCreationTests {
239 if let Err(e) = check_404_for_nonexistent_repo(relay_domain, &npub, fake_repo_id).await { 240 if let Err(e) = check_404_for_nonexistent_repo(relay_domain, &npub, fake_repo_id).await {
240 return TestResult::new( 241 return TestResult::new(
241 test_name, 242 test_name,
242 "GRASP-01:git-http:44", 243 SpecRef::GitServeWebpage,
243 "Relay SHOULD return 404 for repositories it doesn't host", 244 "Relay SHOULD return 404 for repositories it doesn't host",
244 ) 245 )
245 .fail(format!("Expected 404, got: {}", e)); 246 .fail(format!("Expected 404, got: {}", e));
@@ -247,7 +248,7 @@ impl RepositoryCreationTests {
247 248
248 TestResult::new( 249 TestResult::new(
249 test_name, 250 test_name,
250 "GRASP-01:git-http:44", 251 SpecRef::GitServeWebpage,
251 "Relay SHOULD return 404 for repositories it doesn't host", 252 "Relay SHOULD return 404 for repositories it doesn't host",
252 ) 253 )
253 .pass() 254 .pass()
diff --git a/grasp-audit/src/specs/grasp01/spec_requirements.rs b/grasp-audit/src/specs/grasp01/spec_requirements.rs
index 71b2d69..6bc961c 100644
--- a/grasp-audit/src/specs/grasp01/spec_requirements.rs
+++ b/grasp-audit/src/specs/grasp01/spec_requirements.rs
@@ -6,9 +6,36 @@
6/// GRASP spec repository commit ID that this version is based on 6/// GRASP spec repository commit ID that this version is based on
7pub const GRASP_COMMIT_ID: &str = "1fdb8f7"; 7pub const GRASP_COMMIT_ID: &str = "1fdb8f7";
8 8
9/// Reference to a specific GRASP-01 specification requirement
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum SpecRef {
12 NostrRelayNip01Compliant,
13 NostrRelayRejectMissingCloneRelays,
14 NostrRelayMayRejectOtherCriteria,
15 NostrRelayMustAcceptTaggedEvents,
16 NostrRelayMayRejectSpamCuration,
17 PurgatoryAcceptUntilGitData,
18 Nip11ServeDocument,
19 Nip11ListSupportedGrasps,
20 Nip11ListRepoAcceptanceCriteria,
21 Nip11ListCurationPolicy,
22 GitServeRepository,
23 GitAcceptPushesAlignState,
24 GitSetHeadOnReceive,
25 GitAcceptRefsNostrEventId,
26 GitIncludeAllowSha1InWant,
27 GitServeWebpage,
28 CorsAllowOrigin,
29 CorsAllowMethods,
30 CorsAllowHeaders,
31 CorsOptionsResponse,
32}
33
9/// A single specification requirement 34/// A single specification requirement
10#[derive(Debug, Clone)] 35#[derive(Debug, Clone)]
11pub struct SpecRequirement { 36pub struct SpecRequirement {
37 /// Unique reference to this requirement
38 pub spec_ref: SpecRef,
12 /// Line number in the spec document 39 /// Line number in the spec document
13 pub line: u32, 40 pub line: u32,
14 /// Section name (e.g., "Nostr Relay", "Git Smart HTTP Service", "CORS Support") 41 /// Section name (e.g., "Nostr Relay", "Git Smart HTTP Service", "CORS Support")
@@ -37,121 +64,175 @@ impl std::fmt::Display for RequirementLevel {
37 } 64 }
38} 65}
39 66
67impl SpecRef {
68 /// Get the spec reference string in format "GRASP-01:section:line"
69 pub fn spec_ref_string(self) -> &'static str {
70 match self {
71 SpecRef::NostrRelayNip01Compliant => "GRASP-01:nostr-relay:7",
72 SpecRef::NostrRelayRejectMissingCloneRelays => "GRASP-01:nostr-relay:9",
73 SpecRef::NostrRelayMayRejectOtherCriteria => "GRASP-01:nostr-relay:11",
74 SpecRef::NostrRelayMustAcceptTaggedEvents => "GRASP-01:nostr-relay:13",
75 SpecRef::NostrRelayMayRejectSpamCuration => "GRASP-01:nostr-relay:18",
76 SpecRef::PurgatoryAcceptUntilGitData => "GRASP-01:purgatory:22",
77 SpecRef::Nip11ServeDocument => "GRASP-01:nip-11:26",
78 SpecRef::Nip11ListSupportedGrasps => "GRASP-01:nip-11:28",
79 SpecRef::Nip11ListRepoAcceptanceCriteria => "GRASP-01:nip-11:29",
80 SpecRef::Nip11ListCurationPolicy => "GRASP-01:nip-11:30",
81 SpecRef::GitServeRepository => "GRASP-01:git-http:34",
82 SpecRef::GitAcceptPushesAlignState => "GRASP-01:git-http:36",
83 SpecRef::GitSetHeadOnReceive => "GRASP-01:git-http:39",
84 SpecRef::GitAcceptRefsNostrEventId => "GRASP-01:git-http:45",
85 SpecRef::GitIncludeAllowSha1InWant => "GRASP-01:git-http:56",
86 SpecRef::GitServeWebpage => "GRASP-01:git-http:58",
87 SpecRef::CorsAllowOrigin => "GRASP-01:cors:64",
88 SpecRef::CorsAllowMethods => "GRASP-01:cors:65",
89 SpecRef::CorsAllowHeaders => "GRASP-01:cors:66",
90 SpecRef::CorsOptionsResponse => "GRASP-01:cors:67",
91 }
92 }
93}
94
40/// All GRASP-01 specification requirements 95/// All GRASP-01 specification requirements
41pub const GRASP_01_REQUIREMENTS: &[SpecRequirement] = &[ 96pub const GRASP_01_REQUIREMENTS: &[SpecRequirement] = &[
42 // Nostr Relay section 97 // Nostr Relay section
43 SpecRequirement { 98 SpecRequirement {
99 spec_ref: SpecRef::NostrRelayNip01Compliant,
44 line: 7, 100 line: 7,
45 section: "Nostr Relay", 101 section: "Nostr Relay",
46 text: "MUST serve a NIP-01 compliant nostr relay at `/` that accepts git repository announcements and their corresponding repo state announcements.", 102 text: "MUST serve a NIP-01 compliant nostr relay at `/` that accepts git repository announcements and their corresponding repo state announcements.",
47 level: RequirementLevel::Must, 103 level: RequirementLevel::Must,
48 }, 104 },
49 SpecRequirement { 105 SpecRequirement {
106 spec_ref: SpecRef::NostrRelayRejectMissingCloneRelays,
50 line: 9, 107 line: 9,
51 section: "Nostr Relay", 108 section: "Nostr Relay",
52 text: "MUST reject git repository announcements that do not list the service in both `clone` and `relays` tags unless implementing `GRASP-05`.", 109 text: "MUST reject git repository announcements that do not list the service in both `clone` and `relays` tags unless implementing `GRASP-05`.",
53 level: RequirementLevel::Must, 110 level: RequirementLevel::Must,
54 }, 111 },
55 SpecRequirement { 112 SpecRequirement {
113 spec_ref: SpecRef::NostrRelayMayRejectOtherCriteria,
56 line: 11, 114 line: 11,
57 section: "Nostr Relay", 115 section: "Nostr Relay",
58 text: "MAY reject git repository announcements based on other criteria such as pre-payment, quotas, WoT, whitelist, SPAM prevention, etc.", 116 text: "MAY reject git repository announcements based on other criteria such as pre-payment, quotas, WoT, whitelist, SPAM prevention, etc.",
59 level: RequirementLevel::May, 117 level: RequirementLevel::May,
60 }, 118 },
61 SpecRequirement { 119 SpecRequirement {
120 spec_ref: SpecRef::NostrRelayMustAcceptTaggedEvents,
62 line: 13, 121 line: 13,
63 section: "Nostr Relay", 122 section: "Nostr Relay",
64 text: "MUST accept other events that tag, or are tagged by, either: 1. accepted git repository announcements; or 2. accepted issues or patches", 123 text: "MUST accept other events that tag, or are tagged by, either: 1. accepted git repository announcements; or 2. accepted issues or patches",
65 level: RequirementLevel::Must, 124 level: RequirementLevel::Must,
66 }, 125 },
67 SpecRequirement { 126 SpecRequirement {
127 spec_ref: SpecRef::NostrRelayMayRejectSpamCuration,
68 line: 18, 128 line: 18,
69 section: "Nostr Relay", 129 section: "Nostr Relay",
70 text: "MAY reject or delete events for generic SPAM prevention reasons or curation eg. WoT, whitelist, user bans and banned topics.", 130 text: "MAY reject or delete events for generic SPAM prevention reasons or curation eg. WoT, whitelist, user bans and banned topics.",
71 level: RequirementLevel::May, 131 level: RequirementLevel::May,
72 }, 132 },
73 SpecRequirement { 133 SpecRequirement {
134 spec_ref: SpecRef::PurgatoryAcceptUntilGitData,
135 line: 22,
136 section: "Purgatory",
137 text: "New repository announcements, repo state announcements, PRs and PR Updates SHOULD be accepted with message \"purgatory: won't be served until git data arrives\" and kept in purgatory (not served) until the related git data arrives and otherwise discarded after 30 minutes.",
138 level: RequirementLevel::Should,
139 },
140 SpecRequirement {
141 spec_ref: SpecRef::Nip11ServeDocument,
74 line: 26, 142 line: 26,
75 section: "Nostr Relay", 143 section: "NIP-11",
76 text: "MUST serve a NIP-11 document", 144 text: "MUST serve a NIP-11 document",
77 level: RequirementLevel::Must, 145 level: RequirementLevel::Must,
78 }, 146 },
79 SpecRequirement { 147 SpecRequirement {
148 spec_ref: SpecRef::Nip11ListSupportedGrasps,
80 line: 28, 149 line: 28,
81 section: "Nostr Relay", 150 section: "NIP-11",
82 text: "MUST list each supported GRASP under `supported_grasps` in format `GRASP-XX` eg `GRASP-01` as a string array", 151 text: "MUST list each supported GRASP under `supported_grasps` in format `GRASP-XX` eg `GRASP-01` as a string array",
83 level: RequirementLevel::Must, 152 level: RequirementLevel::Must,
84 }, 153 },
85 SpecRequirement { 154 SpecRequirement {
155 spec_ref: SpecRef::Nip11ListRepoAcceptanceCriteria,
86 line: 29, 156 line: 29,
87 section: "Nostr Relay", 157 section: "NIP-11",
88 text: "MUST list repository acceptance criteria under `repo_acceptance_criteria` as a human readable string", 158 text: "MUST list repository acceptance criteria under `repo_acceptance_criteria` as a human readable string",
89 level: RequirementLevel::Must, 159 level: RequirementLevel::Must,
90 }, 160 },
91 SpecRequirement { 161 SpecRequirement {
162 spec_ref: SpecRef::Nip11ListCurationPolicy,
92 line: 30, 163 line: 30,
93 section: "Nostr Relay", 164 section: "NIP-11",
94 text: "MUST list brief summary of curation policy under `curation` if events are curated beyond generic SPAM prevention; otherwise `curation` MUST be omitted", 165 text: "MUST list brief summary of curation policy under `curation` if events are curated beyond generic SPAM prevention; otherwise `curation` MUST be omitted",
95 level: RequirementLevel::Must, 166 level: RequirementLevel::Must,
96 }, 167 },
97 // Git Smart HTTP Service section 168 // Git Smart HTTP Service section
98 SpecRequirement { 169 SpecRequirement {
170 spec_ref: SpecRef::GitServeRepository,
99 line: 34, 171 line: 34,
100 section: "Git Smart HTTP Service", 172 section: "Git Smart HTTP Service",
101 text: "MUST serve a git repository via an unauthenticated git smart http service at `/<npub>/<identifier>.git` for each accepted git repository announcement.", 173 text: "MUST serve a git repository via an unauthenticated git smart http service at `/<npub>/<identifier>.git` for each git repository announcement the relay serves or has in purgatory.",
102 level: RequirementLevel::Must, 174 level: RequirementLevel::Must,
103 }, 175 },
104 SpecRequirement { 176 SpecRequirement {
177 spec_ref: SpecRef::GitAcceptPushesAlignState,
105 line: 36, 178 line: 36,
106 section: "Git Smart HTTP Service", 179 section: "Git Smart HTTP Service",
107 text: "MUST accept pushes via this service that match the latest repo state announcement on the relay, respecting the recursive maintainer set.", 180 text: "MUST accept pushes via this service that fully align the git repository state with a repo state announcement in purgatory that is authorised for this repository, respecting the recursive maintainer set.",
108 level: RequirementLevel::Must, 181 level: RequirementLevel::Must,
109 }, 182 },
110 SpecRequirement { 183 SpecRequirement {
111 line: 38, 184 spec_ref: SpecRef::GitSetHeadOnReceive,
185 line: 39,
112 section: "Git Smart HTTP Service", 186 section: "Git Smart HTTP Service",
113 text: "MUST set repository HEAD per repo state announcement as soon as the git data related to that branch has been received.", 187 text: "As soon as the `receive-pack` is successful, the server MUST: 1. Release the event (and related repository announcement) from purgatory. 2. Align the repository HEAD with the repo state announcement. 3. Synchronize git state with other git repositories on the server for which this state event is authoritative.",
114 level: RequirementLevel::Must, 188 level: RequirementLevel::Must,
115 }, 189 },
116 SpecRequirement { 190 SpecRequirement {
117 line: 40, 191 spec_ref: SpecRef::GitAcceptRefsNostrEventId,
192 line: 45,
118 section: "Git Smart HTTP Service", 193 section: "Git Smart HTTP Service",
119 text: "MUST accept pushes via this service to `refs/nostr/<event-id>` but SHOULD reject if event exists on relay listing a different tip and MAY reject based on criteria such as size, SPAM prevention, etc. SHOULD delete and MAY garbage collect these refs if no corresponding git PR event or git PR update event, with a `c` tag that matches the ref tip, is accepted by relay within 20 minutes.", 194 text: "MUST accept pushes via this service to `refs/nostr/<event-id>` but SHOULD reject if the event exists in purgatory listing a different tip, and MAY reject based on criteria such as size, SPAM prevention, etc.",
120 level: RequirementLevel::Must, 195 level: RequirementLevel::Must,
121 }, 196 },
122 SpecRequirement { 197 SpecRequirement {
123 line: 42, 198 spec_ref: SpecRef::GitIncludeAllowSha1InWant,
199 line: 56,
124 section: "Git Smart HTTP Service", 200 section: "Git Smart HTTP Service",
125 text: "MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` in advertisement and serve available oids.", 201 text: "MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` in advertisement and serve available oids.",
126 level: RequirementLevel::Must, 202 level: RequirementLevel::Must,
127 }, 203 },
128 SpecRequirement { 204 SpecRequirement {
129 line: 44, 205 spec_ref: SpecRef::GitServeWebpage,
206 line: 58,
130 section: "Git Smart HTTP Service", 207 section: "Git Smart HTTP Service",
131 text: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) to browse the repository and a 404 page for repositories it doesn't host.", 208 text: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) to browse the repository and a 404 page for repositories it doesn't host.",
132 level: RequirementLevel::Should, 209 level: RequirementLevel::Should,
133 }, 210 },
134 // CORS Support section 211 // CORS Support section
135 SpecRequirement { 212 SpecRequirement {
136 line: 50, 213 spec_ref: SpecRef::CorsAllowOrigin,
214 line: 64,
137 section: "CORS Support", 215 section: "CORS Support",
138 text: "Set `Access-Control-Allow-Origin: *` on ALL responses", 216 text: "Set `Access-Control-Allow-Origin: *` on ALL responses",
139 level: RequirementLevel::Must, 217 level: RequirementLevel::Must,
140 }, 218 },
141 SpecRequirement { 219 SpecRequirement {
142 line: 51, 220 spec_ref: SpecRef::CorsAllowMethods,
221 line: 65,
143 section: "CORS Support", 222 section: "CORS Support",
144 text: "Set `Access-Control-Allow-Methods: GET, POST` on ALL responses", 223 text: "Set `Access-Control-Allow-Methods: GET, POST` on ALL responses",
145 level: RequirementLevel::Must, 224 level: RequirementLevel::Must,
146 }, 225 },
147 SpecRequirement { 226 SpecRequirement {
148 line: 52, 227 spec_ref: SpecRef::CorsAllowHeaders,
228 line: 66,
149 section: "CORS Support", 229 section: "CORS Support",
150 text: "Set `Access-Control-Allow-Headers: Content-Type` on ALL responses", 230 text: "Set `Access-Control-Allow-Headers: Content-Type` on ALL responses",
151 level: RequirementLevel::Must, 231 level: RequirementLevel::Must,
152 }, 232 },
153 SpecRequirement { 233 SpecRequirement {
154 line: 53, 234 spec_ref: SpecRef::CorsOptionsResponse,
235 line: 67,
155 section: "CORS Support", 236 section: "CORS Support",
156 text: "Respond to OPTIONS requests with 204 No Content", 237 text: "Respond to OPTIONS requests with 204 No Content",
157 level: RequirementLevel::Must, 238 level: RequirementLevel::Must,
@@ -163,6 +244,13 @@ pub fn get_requirement(line: u32) -> Option<&'static SpecRequirement> {
163 GRASP_01_REQUIREMENTS.iter().find(|r| r.line == line) 244 GRASP_01_REQUIREMENTS.iter().find(|r| r.line == line)
164} 245}
165 246
247/// Get a requirement by its SpecRef
248pub fn get_requirement_by_ref(spec_ref: SpecRef) -> Option<&'static SpecRequirement> {
249 GRASP_01_REQUIREMENTS
250 .iter()
251 .find(|r| r.spec_ref == spec_ref)
252}
253
166/// Get all requirements for a section 254/// Get all requirements for a section
167pub fn get_requirements_for_section(section: &str) -> Vec<&'static SpecRequirement> { 255pub fn get_requirements_for_section(section: &str) -> Vec<&'static SpecRequirement> {
168 GRASP_01_REQUIREMENTS 256 GRASP_01_REQUIREMENTS
@@ -194,16 +282,38 @@ mod tests {
194 } 282 }
195 283
196 #[test] 284 #[test]
285 fn test_get_requirement_by_ref() {
286 let req = get_requirement_by_ref(SpecRef::NostrRelayNip01Compliant)
287 .expect("SpecRef should exist");
288 assert_eq!(req.line, 7);
289 assert_eq!(req.spec_ref, SpecRef::NostrRelayNip01Compliant);
290 }
291
292 #[test]
197 fn test_get_sections() { 293 fn test_get_sections() {
198 let sections = get_sections(); 294 let sections = get_sections();
199 assert_eq!(sections.len(), 3); 295 assert_eq!(sections.len(), 5);
200 assert_eq!(sections[0], "Nostr Relay"); 296 assert_eq!(sections[0], "Nostr Relay");
201 assert_eq!(sections[1], "Git Smart HTTP Service"); 297 assert_eq!(sections[1], "Purgatory");
202 assert_eq!(sections[2], "CORS Support"); 298 assert_eq!(sections[2], "NIP-11");
299 assert_eq!(sections[3], "Git Smart HTTP Service");
300 assert_eq!(sections[4], "CORS Support");
203 } 301 }
204 302
205 #[test] 303 #[test]
206 fn test_requirement_count() { 304 fn test_requirement_count() {
207 assert_eq!(GRASP_01_REQUIREMENTS.len(), 19); 305 assert_eq!(GRASP_01_REQUIREMENTS.len(), 20);
306 }
307
308 #[test]
309 fn test_spec_ref_unique() {
310 let mut refs = std::collections::HashSet::new();
311 for req in GRASP_01_REQUIREMENTS {
312 assert!(
313 refs.insert(req.spec_ref),
314 "Duplicate SpecRef found: {:?}",
315 req.spec_ref
316 );
317 }
208 } 318 }
209} 319}
diff --git a/grasp-audit/src/specs/mod.rs b/grasp-audit/src/specs/mod.rs
index bf711fa..ceae684 100644
--- a/grasp-audit/src/specs/mod.rs
+++ b/grasp-audit/src/specs/mod.rs
@@ -7,5 +7,5 @@ pub mod grasp01;
7// Re-export all test structs from grasp01 module 7// Re-export all test structs from grasp01 module
8pub use grasp01::{ 8pub use grasp01::{
9 CorsTests, EventAcceptancePolicyTests, GitCloneTests, GitFilterTests, Nip01SmokeTests, 9 CorsTests, EventAcceptancePolicyTests, GitCloneTests, GitFilterTests, Nip01SmokeTests,
10 Nip11DocumentTests, PushAuthorizationTests, RepositoryCreationTests, 10 Nip11DocumentTests, PurgatoryTests, PushAuthorizationTests, RepositoryCreationTests,
11}; 11};
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index 27107db..df780bb 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -287,6 +287,39 @@ pub async fn fetch_repository_data(
287 }) 287 })
288} 288}
289 289
290/// Fetch repository data including announcements from purgatory
291///
292/// This combines database announcements with purgatory announcements,
293/// which is needed for authorization when the announcement hasn't been
294/// promoted yet (no git data has arrived).
295pub async fn fetch_repository_data_with_purgatory(
296 database: &SharedDatabase,
297 purgatory: &crate::purgatory::Purgatory,
298 identifier: &str,
299) -> Result<RepositoryData> {
300 // First, fetch from database
301 let mut repo_data = fetch_repository_data(database, identifier).await?;
302
303 // Then, add announcements from purgatory
304 let purgatory_announcements = purgatory.get_announcements_by_identifier(identifier);
305 let purgatory_count = purgatory_announcements.len();
306
307 for entry in purgatory_announcements {
308 if let Ok(announcement) = RepositoryAnnouncement::from_event(entry.event) {
309 repo_data.announcements.push(announcement);
310 }
311 }
312
313 debug!(
314 "Fetched repository data with purgatory: {} announcements ({} from purgatory), {} states",
315 repo_data.announcements.len(),
316 purgatory_count,
317 repo_data.states.len()
318 );
319
320 Ok(repo_data)
321}
322
290pub fn pubkey_authorised_for_repo_owners( 323pub fn pubkey_authorised_for_repo_owners(
291 pubkey: &PublicKey, 324 pubkey: &PublicKey,
292 db_repo_data: &RepositoryData, 325 db_repo_data: &RepositoryData,
@@ -539,8 +572,9 @@ pub async fn get_state_authorization_for_specific_owner_repo(
539 use crate::git::list_refs; 572 use crate::git::list_refs;
540 use crate::purgatory::RefUpdate; 573 use crate::purgatory::RefUpdate;
541 574
542 // Fetch announcements only - we don't need database states 575 // Fetch announcements from database AND purgatory - needed for authorization
543 let repo_data = fetch_repository_data(database, identifier).await?; 576 // when the announcement hasn't been promoted yet (no git data has arrived)
577 let repo_data = fetch_repository_data_with_purgatory(database, purgatory, identifier).await?;
544 578
545 if repo_data.announcements.is_empty() { 579 if repo_data.announcements.is_empty() {
546 return Ok(AuthorizationResult::denied( 580 return Ok(AuthorizationResult::denied(
@@ -649,6 +683,27 @@ pub async fn get_state_authorization_for_specific_owner_repo(
649 .unwrap_or_else(|_| latest_authorized.pubkey.to_hex()) 683 .unwrap_or_else(|_| latest_authorized.pubkey.to_hex())
650 ); 684 );
651 685
686 // Extend purgatory announcement expiry for the owner.
687 //
688 // Per design doc decision #4: git auth extending a state event's expiry
689 // also extends the announcement's expiry. The repo is actively receiving
690 // git data, so the announcement should not expire prematurely.
691 // This also revives soft-expired announcements (recreates bare repo).
692 if let Ok(owner_pk) = PublicKey::parse(owner_pubkey) {
693 if purgatory.has_purgatory_announcement(&owner_pk, identifier) {
694 purgatory.extend_announcement_expiry(
695 &owner_pk,
696 identifier,
697 std::time::Duration::from_secs(1800),
698 );
699 debug!(
700 identifier = %identifier,
701 owner = %owner_pubkey,
702 "Extended purgatory announcement expiry due to git push authorization"
703 );
704 }
705 }
706
652 return Ok(AuthorizationResult { 707 return Ok(AuthorizationResult {
653 authorized: true, 708 authorized: true,
654 reason: "Authorized by state event in purgatory".to_string(), 709 reason: "Authorized by state event in purgatory".to_string(),
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
index 28cb47f..f43cbb6 100644
--- a/src/git/handlers.rs
+++ b/src/git/handlers.rs
@@ -17,8 +17,9 @@ use super::subprocess::GitSubprocess;
17 17
18use crate::git::authorization::{authorize_push, parse_pushed_refs}; 18use crate::git::authorization::{authorize_push, parse_pushed_refs};
19use crate::git::sync::process_newly_available_git_data; 19use crate::git::sync::process_newly_available_git_data;
20use crate::nostr::builder::SharedDatabase; 20use crate::nostr::builder::{Nip34WritePolicy, SharedDatabase};
21use crate::purgatory::Purgatory; 21use crate::purgatory::Purgatory;
22use crate::sync::rejected_index::RejectedEventsIndex;
22 23
23/// Handle GET /info/refs?service=git-{upload,receive}-pack 24/// Handle GET /info/refs?service=git-{upload,receive}-pack
24/// 25///
@@ -258,6 +259,8 @@ pub async fn handle_receive_pack(
258 purgatory: Arc<Purgatory>, 259 purgatory: Arc<Purgatory>,
259 git_data_path: &str, 260 git_data_path: &str,
260 git_protocol: Option<&str>, 261 git_protocol: Option<&str>,
262 write_policy: Arc<Nip34WritePolicy>,
263 rejected_events_index: Arc<RejectedEventsIndex>,
261) -> Result<Response<Full<Bytes>>, GitError> { 264) -> Result<Response<Full<Bytes>>, GitError> {
262 debug!("Handling receive-pack for {:?}", repo_path); 265 debug!("Handling receive-pack for {:?}", repo_path);
263 266
@@ -397,6 +400,8 @@ pub async fn handle_receive_pack(
397 Some(&relay), 400 Some(&relay),
398 &purgatory, 401 &purgatory,
399 git_data_path_buf, 402 git_data_path_buf,
403 Some(&write_policy),
404 Some(&rejected_events_index),
400 ) 405 )
401 .await 406 .await
402 { 407 {
diff --git a/src/git/sync.rs b/src/git/sync.rs
index b1a9b49..c24d16b 100644
--- a/src/git/sync.rs
+++ b/src/git/sync.rs
@@ -32,17 +32,20 @@
32use std::collections::{HashMap, HashSet}; 32use std::collections::{HashMap, HashSet};
33use std::path::Path; 33use std::path::Path;
34use std::process::Command; 34use std::process::Command;
35use std::sync::Arc;
35use tracing::{debug, info, warn}; 36use tracing::{debug, info, warn};
36 37
37use nostr_sdk::Event; 38use nostr_sdk::Event;
38 39
39use crate::git::authorization::{ 40use crate::git::authorization::{
40 collect_authorized_maintainers, fetch_repository_data, RepositoryData, 41 collect_authorized_maintainers, fetch_repository_data, fetch_repository_data_with_purgatory,
42 RepositoryData,
41}; 43};
42use crate::git::{self, oid_exists}; 44use crate::git::{self, oid_exists};
43use crate::nostr::builder::SharedDatabase; 45use crate::nostr::builder::{Nip34WritePolicy, SharedDatabase};
44use crate::nostr::events::RepositoryState; 46use crate::nostr::events::RepositoryState;
45use crate::purgatory::{can_apply_state, Purgatory}; 47use crate::purgatory::{can_apply_state, Purgatory};
48use crate::sync::rejected_index::RejectedEventsIndex;
46 49
47/// Result of processing newly available git data. 50/// Result of processing newly available git data.
48/// 51///
@@ -51,6 +54,8 @@ use crate::purgatory::{can_apply_state, Purgatory};
51/// or from purgatory sync fetching OIDs from remote servers). 54/// or from purgatory sync fetching OIDs from remote servers).
52#[derive(Debug, Default, Clone)] 55#[derive(Debug, Default, Clone)]
53pub struct ProcessResult { 56pub struct ProcessResult {
57 /// Number of announcements released from purgatory
58 pub announcements_released: usize,
54 /// Number of state events released from purgatory 59 /// Number of state events released from purgatory
55 pub states_released: usize, 60 pub states_released: usize,
56 /// Number of PR events released from purgatory 61 /// Number of PR events released from purgatory
@@ -70,11 +75,12 @@ pub struct ProcessResult {
70impl ProcessResult { 75impl ProcessResult {
71 /// Check if any events were released 76 /// Check if any events were released
72 pub fn released_any(&self) -> bool { 77 pub fn released_any(&self) -> bool {
73 self.states_released > 0 || self.prs_released > 0 78 self.announcements_released > 0 || self.states_released > 0 || self.prs_released > 0
74 } 79 }
75 80
76 /// Merge another ProcessResult into this one 81 /// Merge another ProcessResult into this one
77 pub fn merge(&mut self, other: ProcessResult) { 82 pub fn merge(&mut self, other: ProcessResult) {
83 self.announcements_released += other.announcements_released;
78 self.states_released += other.states_released; 84 self.states_released += other.states_released;
79 self.prs_released += other.prs_released; 85 self.prs_released += other.prs_released;
80 self.repos_synced += other.repos_synced; 86 self.repos_synced += other.repos_synced;
@@ -815,6 +821,8 @@ pub async fn process_newly_available_git_data(
815 local_relay: Option<&nostr_relay_builder::LocalRelay>, 821 local_relay: Option<&nostr_relay_builder::LocalRelay>,
816 purgatory: &Purgatory, 822 purgatory: &Purgatory,
817 git_data_path: &Path, 823 git_data_path: &Path,
824 write_policy: Option<&Nip34WritePolicy>,
825 rejected_events_index: Option<&Arc<RejectedEventsIndex>>,
818) -> anyhow::Result<ProcessResult> { 826) -> anyhow::Result<ProcessResult> {
819 let mut result = ProcessResult::default(); 827 let mut result = ProcessResult::default();
820 828
@@ -836,6 +844,20 @@ pub async fn process_newly_available_git_data(
836 "Processing newly available git data" 844 "Processing newly available git data"
837 ); 845 );
838 846
847 // Process announcements from purgatory
848 let announcement_result = process_purgatory_announcements(
849 &identifier,
850 source_repo_path,
851 database,
852 local_relay,
853 purgatory,
854 git_data_path,
855 write_policy,
856 rejected_events_index,
857 )
858 .await;
859 result.merge(announcement_result);
860
839 // Process state events from purgatory 861 // Process state events from purgatory
840 let state_result = process_purgatory_state_events( 862 let state_result = process_purgatory_state_events(
841 &identifier, 863 &identifier,
@@ -863,6 +885,7 @@ pub async fn process_newly_available_git_data(
863 if result.released_any() { 885 if result.released_any() {
864 info!( 886 info!(
865 identifier = %identifier, 887 identifier = %identifier,
888 announcements_released = result.announcements_released,
866 states_released = result.states_released, 889 states_released = result.states_released,
867 prs_released = result.prs_released, 890 prs_released = result.prs_released,
868 repos_synced = result.repos_synced, 891 repos_synced = result.repos_synced,
@@ -907,7 +930,10 @@ async fn process_purgatory_state_events(
907 ); 930 );
908 931
909 // Fetch repository data once for all state events 932 // Fetch repository data once for all state events
910 let mut db_repo_data = match fetch_repository_data(database, identifier).await { 933 // IMPORTANT: Use fetch_repository_data_with_purgatory to include announcements
934 // that may still be in purgatory (not yet promoted). This ensures authorization
935 // works correctly even if the announcement promotion happens in the same batch.
936 let mut db_repo_data = match fetch_repository_data_with_purgatory(database, purgatory, identifier).await {
911 Ok(data) => data, 937 Ok(data) => data,
912 Err(e) => { 938 Err(e) => {
913 warn!( 939 warn!(
@@ -1151,6 +1177,9 @@ async fn process_purgatory_pr_events(
1151 ); 1177 );
1152 1178
1153 // Fetch repository data for syncing 1179 // Fetch repository data for syncing
1180 // NOTE: Only fetch from database, NOT purgatory. PR events should only be
1181 // released from purgatory when the announcement has been promoted (validated).
1182 // This ensures we don't accept PR events for announcements that fail validation.
1154 let db_repo_data = match fetch_repository_data(database, identifier).await { 1183 let db_repo_data = match fetch_repository_data(database, identifier).await {
1155 Ok(data) => data, 1184 Ok(data) => data,
1156 Err(e) => { 1185 Err(e) => {
@@ -1250,6 +1279,195 @@ async fn process_purgatory_pr_events(
1250 result 1279 result
1251} 1280}
1252 1281
1282/// Process announcements from purgatory that can now be promoted.
1283///
1284/// When git data arrives for a repository, any announcements in purgatory
1285/// for that repository should be promoted to the database and served to clients.
1286///
1287/// When `write_policy` and `rejected_events_index` are provided (git push path),
1288/// any maintainer announcements sitting in the hot cache are re-processed immediately
1289/// after the owner announcement is promoted, so they don't wait for the next sync cycle.
1290async fn process_purgatory_announcements(
1291 identifier: &str,
1292 source_repo_path: &Path,
1293 database: &SharedDatabase,
1294 local_relay: Option<&nostr_relay_builder::LocalRelay>,
1295 purgatory: &Purgatory,
1296 git_data_path: &Path,
1297 write_policy: Option<&Nip34WritePolicy>,
1298 rejected_events_index: Option<&Arc<RejectedEventsIndex>>,
1299) -> ProcessResult {
1300 let mut result = ProcessResult::default();
1301
1302 // Extract owner pubkey from the source repo path
1303 let owner_pubkey = match extract_owner_from_repo_path(source_repo_path, git_data_path) {
1304 Some(npub) => npub,
1305 None => {
1306 debug!(
1307 identifier = %identifier,
1308 "Could not extract owner from repo path"
1309 );
1310 return result;
1311 }
1312 };
1313
1314 // Parse the npub back to PublicKey
1315 let owner = match nostr_sdk::PublicKey::parse(&owner_pubkey) {
1316 Ok(pk) => pk,
1317 Err(e) => {
1318 warn!(
1319 identifier = %identifier,
1320 owner_pubkey = %owner_pubkey,
1321 error = %e,
1322 "Failed to parse owner pubkey"
1323 );
1324 result.errors.push(format!("Failed to parse owner pubkey: {}", e));
1325 return result;
1326 }
1327 };
1328
1329 // Check if there's an announcement in purgatory for this owner and identifier
1330 let announcement_event = purgatory.promote_announcement(&owner, identifier);
1331
1332 if let Some(event) = announcement_event {
1333 // Save to database
1334 match database.save_event(&event).await {
1335 Ok(_) => {
1336 info!(
1337 identifier = %identifier,
1338 event_id = %event.id,
1339 "Promoted announcement from purgatory to database"
1340 );
1341
1342 // Notify WebSocket subscribers
1343 if let Some(relay) = local_relay {
1344 if relay.notify_event(event.clone()) {
1345 debug!(
1346 identifier = %identifier,
1347 event_id = %event.id,
1348 "Broadcast announcement event to WebSocket listeners"
1349 );
1350 }
1351 }
1352
1353 result.announcements_released += 1;
1354
1355 // Re-process any maintainer announcements sitting in the hot cache.
1356 //
1357 // When an owner announcement is promoted from purgatory via a git push,
1358 // maintainer announcements that arrived earlier (via relay sync) may have
1359 // been rejected and stored in the hot cache because the owner announcement
1360 // didn't exist in the DB yet. Now that the owner announcement is saved,
1361 // we must invalidate and re-process those cached events immediately.
1362 //
1363 // This only applies on the git push path (write_policy + rejected_events_index
1364 // are Some). The purgatory sync path already handles this via
1365 // SyncManager::process_event_static.
1366 if let (Some(wp), Some(rei), Some(relay)) =
1367 (write_policy, rejected_events_index, local_relay)
1368 {
1369 use crate::nostr::events::RepositoryAnnouncement;
1370 use nostr_relay_builder::prelude::{WritePolicy, WritePolicyResult};
1371 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1372
1373 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
1374 if !announcement.maintainers.is_empty() {
1375 debug!(
1376 identifier = %identifier,
1377 event_id = %event.id,
1378 maintainer_count = announcement.maintainers.len(),
1379 "Owner announcement promoted via git push, checking hot cache for rejected maintainer announcements"
1380 );
1381
1382 for maintainer_hex in &announcement.maintainers {
1383 match nostr_sdk::PublicKey::from_hex(maintainer_hex) {
1384 Ok(maintainer_pubkey) => {
1385 let (removed, hot_events) = rei.invalidate_and_get(
1386 &maintainer_pubkey,
1387 &announcement.identifier,
1388 Some(crate::sync::rejected_index::EventType::Announcement),
1389 );
1390
1391 if removed > 0 {
1392 info!(
1393 maintainer = %maintainer_hex,
1394 identifier = %announcement.identifier,
1395 removed_from_cold_index = removed,
1396 hot_cache_events = hot_events.len(),
1397 "Invalidated rejected maintainer announcements after git push promotion"
1398 );
1399 }
1400
1401 // Re-process events from hot cache
1402 let dummy_addr = SocketAddr::new(
1403 IpAddr::V4(Ipv4Addr::LOCALHOST),
1404 0,
1405 );
1406 for hot_event in hot_events {
1407 info!(
1408 event_id = %hot_event.id,
1409 maintainer = %maintainer_hex,
1410 identifier = %announcement.identifier,
1411 "Re-processing maintainer announcement from hot cache after git push promotion"
1412 );
1413 match wp.admit_event(&hot_event, &dummy_addr).await {
1414 WritePolicyResult::Accept => {
1415 match database.save_event(&hot_event).await {
1416 Ok(_) => {
1417 relay.notify_event(hot_event.clone());
1418 info!(
1419 event_id = %hot_event.id,
1420 "Maintainer announcement accepted and saved on re-processing"
1421 );
1422 }
1423 Err(e) => {
1424 warn!(
1425 event_id = %hot_event.id,
1426 error = %e,
1427 "Failed to save re-processed maintainer announcement"
1428 );
1429 }
1430 }
1431 }
1432 _ => {
1433 warn!(
1434 event_id = %hot_event.id,
1435 "Maintainer announcement still rejected on re-processing"
1436 );
1437 }
1438 }
1439 }
1440 }
1441 Err(e) => {
1442 warn!(
1443 maintainer_hex = %maintainer_hex,
1444 error = %e,
1445 "Invalid maintainer public key in promoted announcement"
1446 );
1447 }
1448 }
1449 }
1450 }
1451 }
1452 }
1453 }
1454 Err(e) => {
1455 warn!(
1456 identifier = %identifier,
1457 event_id = %event.id,
1458 error = %e,
1459 "Failed to save announcement to database"
1460 );
1461 result
1462 .errors
1463 .push(format!("Failed to save announcement: {}", e));
1464 }
1465 }
1466 }
1467
1468 result
1469}
1470
1253/// Extract owner pubkey from a repository path. 1471/// Extract owner pubkey from a repository path.
1254/// 1472///
1255/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the npub. 1473/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the npub.
@@ -1271,6 +1489,7 @@ mod tests {
1271 #[test] 1489 #[test]
1272 fn test_process_result_default() { 1490 fn test_process_result_default() {
1273 let result = ProcessResult::default(); 1491 let result = ProcessResult::default();
1492 assert_eq!(result.announcements_released, 0);
1274 assert_eq!(result.states_released, 0); 1493 assert_eq!(result.states_released, 0);
1275 assert_eq!(result.prs_released, 0); 1494 assert_eq!(result.prs_released, 0);
1276 assert_eq!(result.repos_synced, 0); 1495 assert_eq!(result.repos_synced, 0);
@@ -1282,6 +1501,10 @@ mod tests {
1282 let mut result = ProcessResult::default(); 1501 let mut result = ProcessResult::default();
1283 assert!(!result.released_any()); 1502 assert!(!result.released_any());
1284 1503
1504 result.announcements_released = 1;
1505 assert!(result.released_any());
1506
1507 result.announcements_released = 0;
1285 result.states_released = 1; 1508 result.states_released = 1;
1286 assert!(result.released_any()); 1509 assert!(result.released_any());
1287 1510
@@ -1293,6 +1516,7 @@ mod tests {
1293 #[test] 1516 #[test]
1294 fn test_process_result_merge() { 1517 fn test_process_result_merge() {
1295 let mut result1 = ProcessResult { 1518 let mut result1 = ProcessResult {
1519 announcements_released: 0,
1296 states_released: 1, 1520 states_released: 1,
1297 prs_released: 2, 1521 prs_released: 2,
1298 repos_synced: 3, 1522 repos_synced: 3,
@@ -1303,6 +1527,7 @@ mod tests {
1303 }; 1527 };
1304 1528
1305 let result2 = ProcessResult { 1529 let result2 = ProcessResult {
1530 announcements_released: 5,
1306 states_released: 10, 1531 states_released: 10,
1307 prs_released: 20, 1532 prs_released: 20,
1308 repos_synced: 30, 1533 repos_synced: 30,
@@ -1314,6 +1539,7 @@ mod tests {
1314 1539
1315 result1.merge(result2); 1540 result1.merge(result2);
1316 1541
1542 assert_eq!(result1.announcements_released, 5);
1317 assert_eq!(result1.states_released, 11); 1543 assert_eq!(result1.states_released, 11);
1318 assert_eq!(result1.prs_released, 22); 1544 assert_eq!(result1.prs_released, 22);
1319 assert_eq!(result1.repos_synced, 33); 1545 assert_eq!(result1.repos_synced, 33);
diff --git a/src/http/mod.rs b/src/http/mod.rs
index edc28a3..76ffef3 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -26,8 +26,9 @@ use tokio::net::TcpListener;
26use crate::config::Config; 26use crate::config::Config;
27use crate::git; 27use crate::git;
28use crate::metrics::Metrics; 28use crate::metrics::Metrics;
29use crate::nostr::builder::SharedDatabase; 29use crate::nostr::builder::{Nip34WritePolicy, SharedDatabase};
30use crate::purgatory::Purgatory; 30use crate::purgatory::Purgatory;
31use crate::sync::rejected_index::RejectedEventsIndex;
31 32
32/// CORS headers required by GRASP-01 specification (lines 40-47) 33/// CORS headers required by GRASP-01 specification (lines 40-47)
33const CORS_ALLOW_ORIGIN: &str = "*"; 34const CORS_ALLOW_ORIGIN: &str = "*";
@@ -97,6 +98,10 @@ struct HttpService {
97 metrics: Option<Arc<Metrics>>, 98 metrics: Option<Arc<Metrics>>,
98 /// Purgatory for event/git coordination 99 /// Purgatory for event/git coordination
99 purgatory: Arc<Purgatory>, 100 purgatory: Arc<Purgatory>,
101 /// Write policy for re-processing hot-cache events after git push promotion
102 write_policy: Arc<Nip34WritePolicy>,
103 /// Rejected events index for hot-cache re-processing after git push promotion
104 rejected_events_index: Arc<RejectedEventsIndex>,
100} 105}
101 106
102impl HttpService { 107impl HttpService {
@@ -107,6 +112,8 @@ impl HttpService {
107 database: SharedDatabase, 112 database: SharedDatabase,
108 metrics: Option<Arc<Metrics>>, 113 metrics: Option<Arc<Metrics>>,
109 purgatory: Arc<Purgatory>, 114 purgatory: Arc<Purgatory>,
115 write_policy: Arc<Nip34WritePolicy>,
116 rejected_events_index: Arc<RejectedEventsIndex>,
110 ) -> Self { 117 ) -> Self {
111 Self { 118 Self {
112 relay, 119 relay,
@@ -115,6 +122,8 @@ impl HttpService {
115 database, 122 database,
116 metrics, 123 metrics,
117 purgatory, 124 purgatory,
125 write_policy,
126 rejected_events_index,
118 } 127 }
119 } 128 }
120} 129}
@@ -132,6 +141,8 @@ impl Service<Request<Incoming>> for HttpService {
132 let git_data_path = self.config.effective_git_data_path(); 141 let git_data_path = self.config.effective_git_data_path();
133 let database = self.database.clone(); 142 let database = self.database.clone();
134 let purgatory = self.purgatory.clone(); 143 let purgatory = self.purgatory.clone();
144 let write_policy = self.write_policy.clone();
145 let rejected_events_index = self.rejected_events_index.clone();
135 146
136 // Handle OPTIONS preflight requests (CORS) 147 // Handle OPTIONS preflight requests (CORS)
137 // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content 148 // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content
@@ -293,6 +304,8 @@ impl Service<Request<Incoming>> for HttpService {
293 purgatory.clone(), 304 purgatory.clone(),
294 &git_data_path, 305 &git_data_path,
295 git_protocol.as_deref(), 306 git_protocol.as_deref(),
307 write_policy.clone(),
308 rejected_events_index.clone(),
296 ) 309 )
297 .await; 310 .await;
298 311
@@ -557,12 +570,17 @@ fn derive_accept_key(request_key: &[u8]) -> String {
557/// * `relay` - The LocalRelay for WebSocket connections 570/// * `relay` - The LocalRelay for WebSocket connections
558/// * `database` - The database for direct queries (e.g., push authorization) 571/// * `database` - The database for direct queries (e.g., push authorization)
559/// * `metrics` - Optional metrics for Prometheus endpoint 572/// * `metrics` - Optional metrics for Prometheus endpoint
573/// * `purgatory` - Purgatory for event/git coordination
574/// * `write_policy` - Write policy for re-processing hot-cache events after git push promotion
575/// * `rejected_events_index` - Rejected events index for hot-cache re-processing
560pub async fn run_server( 576pub async fn run_server(
561 config: Config, 577 config: Config,
562 relay: LocalRelay, 578 relay: LocalRelay,
563 database: SharedDatabase, 579 database: SharedDatabase,
564 metrics: Option<Arc<Metrics>>, 580 metrics: Option<Arc<Metrics>>,
565 purgatory: Arc<Purgatory>, 581 purgatory: Arc<Purgatory>,
582 write_policy: Arc<Nip34WritePolicy>,
583 rejected_events_index: Arc<RejectedEventsIndex>,
566) -> anyhow::Result<()> { 584) -> anyhow::Result<()> {
567 let bind_addr: SocketAddr = config.bind_address.parse()?; 585 let bind_addr: SocketAddr = config.bind_address.parse()?;
568 586
@@ -582,6 +600,8 @@ pub async fn run_server(
582 database.clone(), 600 database.clone(),
583 metrics.clone(), 601 metrics.clone(),
584 purgatory.clone(), 602 purgatory.clone(),
603 write_policy.clone(),
604 rejected_events_index.clone(),
585 ); 605 );
586 606
587 tokio::spawn(async move { 607 tokio::spawn(async move {
diff --git a/src/main.rs b/src/main.rs
index dd2c903..bf3aefb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -130,7 +130,9 @@ async fn main() -> Result<()> {
130 } 130 }
131 131
132 // Get a reference to the rejected events index for shutdown persistence 132 // Get a reference to the rejected events index for shutdown persistence
133 // and for the HTTP server's git push path (hot-cache re-processing)
133 let shutdown_rejected_index = sync_manager.rejected_events_index(); 134 let shutdown_rejected_index = sync_manager.rejected_events_index();
135 let http_rejected_index = shutdown_rejected_index.clone();
134 136
135 tokio::spawn(async move { 137 tokio::spawn(async move {
136 sync_manager.run().await; 138 sync_manager.run().await;
@@ -142,11 +144,11 @@ async fn main() -> Result<()> {
142 let mut interval = tokio::time::interval(Duration::from_secs(60)); 144 let mut interval = tokio::time::interval(Duration::from_secs(60));
143 loop { 145 loop {
144 interval.tick().await; 146 interval.tick().await;
145 let (state_removed, pr_removed) = cleanup_purgatory.cleanup(); 147 let (announcement_removed, state_removed, pr_removed) = cleanup_purgatory.cleanup();
146 if state_removed > 0 || pr_removed > 0 { 148 if announcement_removed > 0 || state_removed > 0 || pr_removed > 0 {
147 info!( 149 info!(
148 "Purgatory cleanup: removed {} state events, {} PR events", 150 "Purgatory cleanup: removed {} announcements, {} state events, {} PR events",
149 state_removed, pr_removed 151 announcement_removed, state_removed, pr_removed
150 ); 152 );
151 } 153 }
152 } 154 }
@@ -206,12 +208,15 @@ async fn main() -> Result<()> {
206 // Start HTTP server with integrated relay and database 208 // Start HTTP server with integrated relay and database
207 info!("Starting HTTP server on {}", config.bind_address); 209 info!("Starting HTTP server on {}", config.bind_address);
208 210
211 // Wrap write_policy in Arc for sharing between HTTP server connections
212 let http_write_policy = Arc::new(relay_with_db.write_policy.clone());
213
209 // Run server until shutdown signal, then cleanup 214 // Run server until shutdown signal, then cleanup
210 #[cfg(unix)] 215 #[cfg(unix)]
211 { 216 {
212 use tokio::signal::unix::{signal, SignalKind}; 217 use tokio::signal::unix::{signal, SignalKind};
213 let mut sigterm = signal(SignalKind::terminate())?; 218 let mut sigterm = signal(SignalKind::terminate())?;
214 219
215 tokio::select! { 220 tokio::select! {
216 result = http::run_server( 221 result = http::run_server(
217 config, 222 config,
@@ -219,6 +224,8 @@ async fn main() -> Result<()> {
219 relay_with_db.database, 224 relay_with_db.database,
220 metrics, 225 metrics,
221 purgatory, 226 purgatory,
227 http_write_policy,
228 http_rejected_index,
222 ) => { 229 ) => {
223 result? 230 result?
224 } 231 }
@@ -230,7 +237,7 @@ async fn main() -> Result<()> {
230 } 237 }
231 } 238 }
232 } 239 }
233 240
234 #[cfg(not(unix))] 241 #[cfg(not(unix))]
235 { 242 {
236 tokio::select! { 243 tokio::select! {
@@ -240,6 +247,8 @@ async fn main() -> Result<()> {
240 relay_with_db.database, 247 relay_with_db.database,
241 metrics, 248 metrics,
242 purgatory, 249 purgatory,
250 http_write_policy,
251 http_rejected_index,
243 ) => { 252 ) => {
244 result? 253 result?
245 } 254 }
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 713c129..7a05348 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -14,10 +14,11 @@ use nostr_relay_builder::prelude::*;
14use crate::config::{Config, DatabaseBackend}; 14use crate::config::{Config, DatabaseBackend};
15use crate::nostr::events::RepositoryAnnouncement; 15use crate::nostr::events::RepositoryAnnouncement;
16use crate::nostr::policy::{ 16use crate::nostr::policy::{
17 AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult, 17 AnnouncementPolicy, AnnouncementResult, DeletionPolicy, PolicyContext, PrEventPolicy,
18 RelatedEventPolicy, StatePolicy, StateResult, 18 ReferenceResult, RelatedEventPolicy, StatePolicy, StateResult,
19}; 19};
20 20
21
21/// Type alias for the shared database used by the relay 22/// Type alias for the shared database used by the relay
22pub type SharedDatabase = Arc<dyn NostrDatabase>; 23pub type SharedDatabase = Arc<dyn NostrDatabase>;
23 24
@@ -28,6 +29,7 @@ pub type SharedDatabase = Arc<dyn NostrDatabase>;
28/// - `StatePolicy` - State event validation + ref alignment 29/// - `StatePolicy` - State event validation + ref alignment
29/// - `PrEventPolicy` - PR/PR Update validation 30/// - `PrEventPolicy` - PR/PR Update validation
30/// - `RelatedEventPolicy` - Forward/backward reference checking 31/// - `RelatedEventPolicy` - Forward/backward reference checking
32/// - `DeletionPolicy` - NIP-09 event deletion request handling
31/// 33///
32/// Uses stateful database queries to check event relationships. 34/// Uses stateful database queries to check event relationships.
33#[derive(Clone)] 35#[derive(Clone)]
@@ -37,6 +39,7 @@ pub struct Nip34WritePolicy {
37 state_policy: StatePolicy, 39 state_policy: StatePolicy,
38 pr_event_policy: PrEventPolicy, 40 pr_event_policy: PrEventPolicy,
39 related_event_policy: RelatedEventPolicy, 41 related_event_policy: RelatedEventPolicy,
42 deletion_policy: DeletionPolicy,
40} 43}
41 44
42impl std::fmt::Debug for Nip34WritePolicy { 45impl std::fmt::Debug for Nip34WritePolicy {
@@ -68,6 +71,7 @@ impl Nip34WritePolicy {
68 state_policy: StatePolicy::new(ctx.clone()), 71 state_policy: StatePolicy::new(ctx.clone()),
69 pr_event_policy: PrEventPolicy::new(ctx.clone()), 72 pr_event_policy: PrEventPolicy::new(ctx.clone()),
70 related_event_policy: RelatedEventPolicy::new(ctx.clone()), 73 related_event_policy: RelatedEventPolicy::new(ctx.clone()),
74 deletion_policy: DeletionPolicy::new(ctx.clone()),
71 ctx, 75 ctx,
72 } 76 }
73 } 77 }
@@ -205,6 +209,30 @@ impl Nip34WritePolicy {
205 } 209 }
206 } 210 }
207 } 211 }
212 AnnouncementResult::AcceptPurgatory => {
213 // New announcement - add to purgatory
214 match self.announcement_policy.add_to_purgatory(event) {
215 Ok(()) => {
216 tracing::info!(
217 "Accepted announcement to purgatory: {} (waiting for git data)",
218 event_id_str
219 );
220
221 WritePolicyResult::Reject {
222 status: true, // Client sees OK
223 message: "purgatory: won't be served until git data arrives".into(),
224 }
225 }
226 Err(e) => {
227 tracing::warn!(
228 "Failed to add announcement to purgatory {}: {}",
229 event_id_str,
230 e
231 );
232 WritePolicyResult::reject(e)
233 }
234 }
235 }
208 AnnouncementResult::AcceptMaintainer => { 236 AnnouncementResult::AcceptMaintainer => {
209 // Parse announcement to get details for logging 237 // Parse announcement to get details for logging
210 match RepositoryAnnouncement::from_event(event.clone()) { 238 match RepositoryAnnouncement::from_event(event.clone()) {
@@ -621,6 +649,7 @@ impl WritePolicy for Nip34WritePolicy {
621 ); 649 );
622 WritePolicyResult::Accept 650 WritePolicyResult::Accept
623 } 651 }
652 Kind::EventDeletion => self.deletion_policy.handle(event).await,
624 _ => self.handle_related_event(event, "Event").await, 653 _ => self.handle_related_event(event, "Event").await,
625 } 654 }
626 }) 655 })
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
index 15a6e58..b366f0b 100644
--- a/src/nostr/policy/announcement.rs
+++ b/src/nostr/policy/announcement.rs
@@ -3,6 +3,8 @@
3/// Handles validation of NIP-34 repository announcements (kind 30617) 3/// Handles validation of NIP-34 repository announcements (kind 30617)
4/// according to GRASP-01 specification. 4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; 5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6use std::collections::HashSet;
7use std::time::Duration;
6 8
7use super::PolicyContext; 9use super::PolicyContext;
8use crate::config::Config; 10use crate::config::Config;
@@ -11,12 +13,14 @@ use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
11/// Result of announcement policy evaluation 13/// Result of announcement policy evaluation
12#[derive(Debug, Clone, PartialEq)] 14#[derive(Debug, Clone, PartialEq)]
13pub enum AnnouncementResult { 15pub enum AnnouncementResult {
14 /// Accept: Event lists our service (GRASP-01 compliant) 16 /// Accept: Event lists our service (GRASP-01 compliant) - replacement announcement
15 Accept, 17 Accept,
16 /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) 18 /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer)
17 AcceptMaintainer, 19 AcceptMaintainer,
18 /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) 20 /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only)
19 AcceptArchive, 21 AcceptArchive,
22 /// Accept to purgatory: New announcement, waiting for git data
23 AcceptPurgatory,
20 /// Reject: Event fails validation with reason 24 /// Reject: Event fails validation with reason
21 Reject(String), 25 Reject(String),
22} 26}
@@ -35,10 +39,13 @@ impl AnnouncementPolicy {
35 39
36 /// Validate a repository announcement event 40 /// Validate a repository announcement event
37 /// 41 ///
38 /// Returns `Accept` if the announcement lists the service properly, 42 /// Returns:
39 /// `AcceptMaintainer` if accepted via maintainer exception, 43 /// - `Accept` if this is a replacement announcement (active announcement exists in DB or
40 /// `AcceptArchive` if accepted via GRASP-05 archive config, 44 /// purgatory)
41 /// or `Reject` with reason. 45 /// - `AcceptPurgatory` if this is a new announcement (no active announcement exists)
46 /// - `AcceptMaintainer` if accepted via maintainer exception
47 /// - `AcceptArchive` if accepted via GRASP-05 archive config
48 /// - `Reject` with reason if validation fails
42 pub async fn validate(&self, event: &Event) -> AnnouncementResult { 49 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
43 // First, try validation (GRASP-01 + GRASP-05) 50 // First, try validation (GRASP-01 + GRASP-05)
44 let validation_result = validate_announcement(event, &self.config); 51 let validation_result = validate_announcement(event, &self.config);
@@ -49,6 +56,23 @@ impl AnnouncementPolicy {
49 // GRASP-01 Exception: Accept announcements from recursive maintainers 56 // GRASP-01 Exception: Accept announcements from recursive maintainers
50 match RepositoryAnnouncement::from_event(event.clone()) { 57 match RepositoryAnnouncement::from_event(event.clone()) {
51 Ok(announcement) => { 58 Ok(announcement) => {
59 // If this pubkey+identifier has a purgatory entry AND the incoming
60 // event is strictly newer, the owner is sending a replacement that
61 // removes our service. Clear the purgatory entry and its bare repo.
62 //
63 // If the incoming event is older than the purgatory entry (e.g. a
64 // relay replay of a superseded announcement), ignore it — the newer
65 // purgatory entry takes precedence and must not be evicted.
66 let should_evict = self
67 .ctx
68 .purgatory
69 .find_announcement(&event.pubkey, &announcement.identifier)
70 .is_some_and(|entry| event.created_at > entry.event.created_at);
71
72 if should_evict {
73 self.remove_purgatory_announcement(&event.pubkey, &announcement.identifier);
74 }
75
52 match self 76 match self
53 .is_maintainer_in_any_announcement( 77 .is_maintainer_in_any_announcement(
54 &announcement.identifier, 78 &announcement.identifier,
@@ -67,11 +91,221 @@ impl AnnouncementPolicy {
67 Err(_) => AnnouncementResult::Reject(reason), 91 Err(_) => AnnouncementResult::Reject(reason),
68 } 92 }
69 } 93 }
70 // Accept, AcceptArchive, or AcceptMaintainer - return as-is 94 AnnouncementResult::Accept | AnnouncementResult::AcceptArchive => {
95 // Parse announcement to check for existing active announcement
96 match RepositoryAnnouncement::from_event(event.clone()) {
97 Ok(announcement) => {
98 let in_db = match self
99 .has_db_announcement(&event.pubkey, &announcement.identifier)
100 .await
101 {
102 Ok(v) => v,
103 Err(e) => {
104 tracing::warn!(
105 error = %e,
106 "Failed to check for existing DB announcement - rejecting"
107 );
108 return AnnouncementResult::Reject(format!(
109 "Database error checking existing announcement: {}",
110 e
111 ));
112 }
113 };
114
115 if in_db {
116 // Replacement announcement with DB entry - accept immediately
117 tracing::debug!(
118 identifier = %announcement.identifier,
119 "Replacement announcement (DB) - accepting immediately"
120 );
121 return validation_result;
122 }
123
124 let in_purgatory = self
125 .ctx
126 .purgatory
127 .has_purgatory_announcement(&event.pubkey, &announcement.identifier);
128
129 if in_purgatory {
130 // Replacement announcement with purgatory entry - replace it and
131 // extend expiry so the new announcement gets a fresh 30-minute window.
132 tracing::debug!(
133 identifier = %announcement.identifier,
134 "Replacement announcement (purgatory) - replacing purgatory entry"
135 );
136 self.replace_purgatory_announcement(event, &announcement);
137 // Return Accept (not AcceptPurgatory) - this is a replacement, not new
138 return validation_result;
139 }
140
141 // No existing announcement - route to purgatory
142 tracing::debug!(
143 identifier = %announcement.identifier,
144 "New announcement - routing to purgatory"
145 );
146 AnnouncementResult::AcceptPurgatory
147 }
148 Err(e) => AnnouncementResult::Reject(format!(
149 "Failed to parse announcement: {}",
150 e
151 )),
152 }
153 }
154 // AcceptPurgatory shouldn't come from validate_announcement, but handle it
71 result => result, 155 result => result,
72 } 156 }
73 } 157 }
74 158
159 /// Replace a purgatory announcement entry with a newer event.
160 ///
161 /// Called when a replacement announcement arrives for a (pubkey, identifier) pair
162 /// that is currently in purgatory. Updates the purgatory entry and extends the
163 /// expiry so the new announcement has a fresh waiting window.
164 fn replace_purgatory_announcement(
165 &self,
166 event: &Event,
167 announcement: &RepositoryAnnouncement,
168 ) {
169 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
170 let relays: HashSet<String> = announcement.relays.iter().cloned().collect();
171
172 // add_announcement uses the (owner, identifier) key so it overwrites the old entry
173 self.ctx.purgatory.add_announcement(
174 event.clone(),
175 announcement.identifier.clone(),
176 event.pubkey,
177 repo_path,
178 relays,
179 );
180
181 // Extend the announcement's expiry (reset to full 30 min window)
182 self.ctx.purgatory.extend_announcement_expiry(
183 &event.pubkey,
184 &announcement.identifier,
185 Duration::from_secs(1800),
186 );
187
188 // Also extend any state events waiting for this identifier
189 let state_entries = self.ctx.purgatory.find_state(&announcement.identifier);
190 if !state_entries.is_empty() {
191 let state_ids: Vec<_> = state_entries.iter().map(|e| e.event.id).collect();
192 self.ctx.purgatory.extend_expiry(
193 &announcement.identifier,
194 &state_ids,
195 Duration::from_secs(1800),
196 );
197 }
198 }
199
200 /// Remove a purgatory announcement and clean up associated resources.
201 ///
202 /// Called when a replacement announcement is rejected (owner removed our service).
203 /// Deletes the bare repository from disk and removes any state events waiting for
204 /// this identifier.
205 fn remove_purgatory_announcement(&self, pubkey: &PublicKey, identifier: &str) {
206 // Get the repo path before removing from purgatory
207 if let Some(entry) = self.ctx.purgatory.find_announcement(pubkey, identifier) {
208 // Delete the bare repository from disk
209 if entry.repo_path.exists() {
210 if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) {
211 tracing::warn!(
212 path = %entry.repo_path.display(),
213 error = %e,
214 "Failed to delete bare repository during purgatory cleanup"
215 );
216 } else {
217 tracing::info!(
218 path = %entry.repo_path.display(),
219 "Deleted bare repository for rejected purgatory announcement"
220 );
221 }
222 }
223 }
224
225 // Remove the announcement from purgatory
226 self.ctx.purgatory.remove_announcement(pubkey, identifier);
227
228 // Only remove state events if no other owner still has an announcement in purgatory
229 // for this identifier. State events are keyed by identifier alone, so blindly removing
230 // them would also discard state events legitimately belonging to a different owner's
231 // repository that happens to share the same identifier string.
232 let other_owners_remain = !self
233 .ctx
234 .purgatory
235 .get_announcements_by_identifier(identifier)
236 .is_empty();
237
238 if !other_owners_remain {
239 self.ctx.purgatory.remove_state(identifier);
240 }
241
242 tracing::info!(
243 identifier = %identifier,
244 other_owners_remain = %other_owners_remain,
245 "Cleared purgatory entry: owner removed our service from announcement"
246 );
247 }
248
249 /// Check if there's an announcement in the database for this (pubkey, identifier).
250 ///
251 /// Only checks the database (promoted announcements). For purgatory checks use
252 /// `purgatory.has_purgatory_announcement()` directly.
253 async fn has_db_announcement(
254 &self,
255 pubkey: &PublicKey,
256 identifier: &str,
257 ) -> Result<bool, String> {
258 let filter = Filter::new()
259 .kind(Kind::GitRepoAnnouncement)
260 .author(*pubkey)
261 .custom_tag(
262 SingleLetterTag::lowercase(Alphabet::D),
263 identifier.to_string(),
264 );
265
266 let events: Vec<Event> = match self.ctx.database.query(filter).await {
267 Ok(events) => events.into_iter().collect(),
268 Err(e) => return Err(format!("Database query failed: {}", e)),
269 };
270
271 Ok(!events.is_empty())
272 }
273
274 /// Add an announcement to purgatory
275 ///
276 /// Creates the bare repository and stores the announcement in purgatory
277 /// until git data arrives.
278 pub fn add_to_purgatory(&self, event: &Event) -> Result<(), String> {
279 let announcement = RepositoryAnnouncement::from_event(event.clone())
280 .map_err(|e| format!("Failed to parse announcement: {}", e))?;
281
282 // Create bare repository
283 self.ensure_bare_repository(&announcement)?;
284
285 // Build repo path
286 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
287
288 // Extract relays from announcement
289 let relays: HashSet<String> = announcement.relays.iter().cloned().collect();
290
291 // Add to purgatory
292 self.ctx.purgatory.add_announcement(
293 event.clone(),
294 announcement.identifier.clone(),
295 event.pubkey,
296 repo_path,
297 relays,
298 );
299
300 tracing::info!(
301 identifier = %announcement.identifier,
302 event_id = %event.id,
303 "Added announcement to purgatory"
304 );
305
306 Ok(())
307 }
308
75 /// Create a bare git repository if it doesn't exist 309 /// Create a bare git repository if it doesn't exist
76 /// Path format: <git_data_path>/<npub>/<identifier>.git 310 /// Path format: <git_data_path>/<npub>/<identifier>.git
77 pub fn ensure_bare_repository( 311 pub fn ensure_bare_repository(
@@ -117,6 +351,11 @@ impl AnnouncementPolicy {
117 /// 351 ///
118 /// This enables accepting announcements from maintainers even when they don't list 352 /// This enables accepting announcements from maintainers even when they don't list
119 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. 353 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
354 ///
355 /// Checks both the database (promoted announcements) and purgatory (announcements
356 /// waiting for git data). This is necessary because a maintainer's announcement
357 /// (which lists the recursive maintainer) may still be in purgatory when the
358 /// recursive maintainer's announcement arrives.
120 async fn is_maintainer_in_any_announcement( 359 async fn is_maintainer_in_any_announcement(
121 &self, 360 &self,
122 identifier: &str, 361 identifier: &str,
@@ -128,12 +367,26 @@ impl AnnouncementPolicy {
128 identifier.to_string(), 367 identifier.to_string(),
129 ); 368 );
130 369
131 let announcements: Vec<Event> = match self.ctx.database.query(filter).await { 370 let db_announcements: Vec<Event> = match self.ctx.database.query(filter).await {
132 Ok(events) => events.into_iter().collect(), 371 Ok(events) => events.into_iter().collect(),
133 Err(e) => return Err(format!("Database query failed: {}", e)), 372 Err(e) => return Err(format!("Database query failed: {}", e)),
134 }; 373 };
135 374
136 if announcements.is_empty() { 375 // Also collect purgatory announcements for this identifier
376 let purgatory_announcements: Vec<Event> = self
377 .ctx
378 .purgatory
379 .get_announcements_by_identifier(identifier)
380 .into_iter()
381 .map(|entry| entry.event)
382 .collect();
383
384 let all_announcements: Vec<&Event> = db_announcements
385 .iter()
386 .chain(purgatory_announcements.iter())
387 .collect();
388
389 if all_announcements.is_empty() {
137 // No existing announcements for this identifier - author cannot be a maintainer 390 // No existing announcements for this identifier - author cannot be a maintainer
138 return Ok(false); 391 return Ok(false);
139 } 392 }
@@ -141,14 +394,14 @@ impl AnnouncementPolicy {
141 let author_hex = author.to_hex(); 394 let author_hex = author.to_hex();
142 395
143 // Check each announcement to see if author is listed as a maintainer 396 // Check each announcement to see if author is listed as a maintainer
144 for event in &announcements { 397 for event in &all_announcements {
145 // Check if author is the owner of this announcement 398 // Check if author is the owner of this announcement
146 if event.pubkey == *author { 399 if event.pubkey == *author {
147 return Ok(true); 400 return Ok(true);
148 } 401 }
149 402
150 // Check if author is listed in the maintainers tag 403 // Check if author is listed in the maintainers tag
151 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { 404 if let Ok(announcement) = RepositoryAnnouncement::from_event((*event).clone()) {
152 if announcement.maintainers.contains(&author_hex) { 405 if announcement.maintainers.contains(&author_hex) {
153 return Ok(true); 406 return Ok(true);
154 } 407 }
diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs
new file mode 100644
index 0000000..6457c90
--- /dev/null
+++ b/src/nostr/policy/deletion.rs
@@ -0,0 +1,498 @@
1/// Deletion Policy - NIP-09 event deletion request handling
2///
3/// Handles kind 5 (EventDeletion) events that request removal of purgatory entries
4/// for repository announcements (kind 30617) and state events (kind 30618).
5///
6/// ## NIP-09 Rules Enforced
7///
8/// - Only the event author can delete their own events (pubkey must match)
9/// - `e` tags reference specific event IDs to delete
10/// - `a` tags reference addressable events by coordinate (`<kind>:<pubkey>:<d-identifier>`)
11/// - When an `a` tag is used, all versions up to `created_at` of the deletion request
12/// are considered deleted
13///
14/// ## Purgatory Interaction
15///
16/// - Kind 30617 (announcement) in purgatory: entry removed, bare repo deleted from disk
17/// - Kind 30618 (state event) in purgatory: matching state event(s) removed by event ID
18/// or by (author, identifier) coordinate
19use nostr_relay_builder::prelude::{Event, WritePolicyResult};
20
21use super::PolicyContext;
22
23/// Policy for handling NIP-09 event deletion requests
24#[derive(Clone)]
25pub struct DeletionPolicy {
26 ctx: PolicyContext,
27}
28
29impl DeletionPolicy {
30 pub fn new(ctx: PolicyContext) -> Self {
31 Self { ctx }
32 }
33
34 /// Process a kind 5 (EventDeletion) event.
35 ///
36 /// Checks whether the deletion request targets any purgatory announcements
37 /// and removes them if so. The deletion event itself is always accepted
38 /// (relays should store deletion requests per NIP-09).
39 ///
40 /// Only the event author can delete their own events — this is enforced by
41 /// checking that the purgatory entry's owner matches `event.pubkey`.
42 pub async fn handle(&self, event: &Event) -> WritePolicyResult {
43 // Process purgatory removals synchronously (no async needed)
44 self.remove_purgatory_targets(event);
45
46 // Always accept the deletion event itself so it is stored and
47 // can prevent re-acceptance of the deleted event in the future.
48 WritePolicyResult::Accept
49 }
50
51 /// Remove any purgatory entries targeted by this deletion event.
52 ///
53 /// Handles both reference styles from NIP-09:
54 /// - `e` tags: event ID references — match against announcement or state event IDs
55 /// - `a` tags: addressable coordinate references — `30617:…` or `30618:…`
56 ///
57 /// Only removes entries where the purgatory entry's author matches the deletion
58 /// event's pubkey (enforces author-only deletion).
59 fn remove_purgatory_targets(&self, event: &Event) {
60 let author = &event.pubkey;
61
62 for tag in event.tags.iter() {
63 let tag_vec = tag.as_slice();
64 if tag_vec.len() < 2 {
65 continue;
66 }
67
68 match tag_vec[0].as_str() {
69 "e" => {
70 // Event ID reference: find purgatory announcement with this event ID
71 let target_id = &tag_vec[1];
72 self.remove_by_event_id(author, target_id, event.created_at.as_secs());
73 }
74 "a" => {
75 // Addressable coordinate reference: `<kind>:<pubkey>:<d-identifier>`
76 let coord = &tag_vec[1];
77 self.remove_by_coordinate(author, coord, event.created_at.as_secs());
78 }
79 _ => {}
80 }
81 }
82 }
83
84 /// Remove a purgatory entry (announcement, state event, or PR event) matched by event ID.
85 ///
86 /// Checks in order: announcements (30617), state events (30618), PR/PR-update events.
87 /// Only removes entries whose author matches `author`.
88 fn remove_by_event_id(
89 &self,
90 author: &nostr_relay_builder::prelude::PublicKey,
91 target_id_hex: &str,
92 _deletion_created_at: u64,
93 ) {
94 // --- Check PR events (kind 1617/1618) first — O(1) direct lookup ---
95 // PR purgatory is keyed by event ID hex, so this is the cheapest check.
96 // Only remove if the entry has an actual event (not a placeholder) and the
97 // event's author matches the deletion request author.
98 if let Some(entry) = self.ctx.purgatory.find_pr(target_id_hex) {
99 if let Some(ref event) = entry.event {
100 if event.pubkey == *author {
101 tracing::info!(
102 event_id = %target_id_hex,
103 author = %author.to_hex(),
104 "Deletion request: removing purgatory PR event by event ID"
105 );
106 self.ctx.purgatory.remove_pr(target_id_hex);
107 return;
108 }
109 }
110 // Entry exists but is a placeholder or wrong author — don't remove
111 return;
112 }
113
114 // --- Check announcements (kind 30617) ---
115 // The DashMap doesn't expose a direct "find by event ID" method, so we use
116 // the announcements_for_sync snapshot to enumerate all (repo_id, _) pairs.
117 let all = self.ctx.purgatory.announcements_for_sync();
118 for (repo_id, _) in all {
119 // repo_id format: "30617:{pubkey_hex}:{identifier}"
120 let parts: Vec<&str> = repo_id.splitn(3, ':').collect();
121 if parts.len() != 3 {
122 continue;
123 }
124 let entry_pubkey_hex = parts[1];
125 let identifier = parts[2];
126
127 if entry_pubkey_hex != author.to_hex() {
128 continue;
129 }
130
131 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
132 if entry.event.id.to_hex() == target_id_hex {
133 tracing::info!(
134 event_id = %target_id_hex,
135 identifier = %identifier,
136 author = %author.to_hex(),
137 "Deletion request: removing purgatory announcement by event ID"
138 );
139 self.evict_purgatory_entry(author, identifier);
140 return; // event IDs are unique
141 }
142 }
143 }
144
145 // --- Check state events (kind 30618) ---
146 // State events are keyed by identifier; scan all identifiers for a match.
147 let state_identifiers = self.ctx.purgatory.get_all_identifiers();
148 for identifier in state_identifiers {
149 let entries = self.ctx.purgatory.find_state(&identifier);
150 for entry in entries {
151 if entry.author == *author && entry.event.id.to_hex() == target_id_hex {
152 tracing::info!(
153 event_id = %target_id_hex,
154 identifier = %identifier,
155 author = %author.to_hex(),
156 "Deletion request: removing purgatory state event by event ID"
157 );
158 self.ctx.purgatory.remove_state_event(&identifier, &entry.event.id);
159 return; // event IDs are unique
160 }
161 }
162 }
163 }
164
165 /// Remove a purgatory entry matched by addressable coordinate.
166 ///
167 /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`.
168 /// Handles kind 30617 (announcements) and kind 30618 (state events).
169 ///
170 /// Per NIP-09, all versions up to `deletion_created_at` are considered deleted.
171 fn remove_by_coordinate(
172 &self,
173 author: &nostr_relay_builder::prelude::PublicKey,
174 coordinate: &str,
175 deletion_created_at: u64,
176 ) {
177 // Parse coordinate: `<kind>:<pubkey>:<d-identifier>`
178 let parts: Vec<&str> = coordinate.splitn(3, ':').collect();
179 if parts.len() != 3 {
180 return;
181 }
182
183 let kind_str = parts[0];
184 let coord_pubkey_hex = parts[1];
185 let identifier = parts[2];
186
187 // The coordinate pubkey must match the deletion event author
188 if coord_pubkey_hex != author.to_hex() {
189 tracing::debug!(
190 coord_pubkey = %coord_pubkey_hex,
191 deletion_author = %author.to_hex(),
192 "Ignoring deletion: coordinate pubkey does not match deletion author"
193 );
194 return;
195 }
196
197 match kind_str {
198 "30617" => {
199 // Announcement purgatory entry
200 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
201 if entry.event.created_at.as_secs() <= deletion_created_at {
202 tracing::info!(
203 identifier = %identifier,
204 author = %author.to_hex(),
205 "Deletion request: removing purgatory announcement by coordinate"
206 );
207 self.evict_purgatory_entry(author, identifier);
208 } else {
209 tracing::debug!(
210 identifier = %identifier,
211 author = %author.to_hex(),
212 "Ignoring deletion: purgatory announcement is newer than deletion request"
213 );
214 }
215 }
216 }
217 "30618" => {
218 // State event purgatory entries for this (author, identifier).
219 // Remove all entries authored by `author` with created_at ≤ deletion_created_at.
220 let entries = self.ctx.purgatory.find_state(identifier);
221 let mut removed = 0usize;
222 for entry in entries {
223 if entry.author == *author
224 && entry.event.created_at.as_secs() <= deletion_created_at
225 {
226 self.ctx.purgatory.remove_state_event(identifier, &entry.event.id);
227 removed += 1;
228 }
229 }
230 if removed > 0 {
231 tracing::info!(
232 identifier = %identifier,
233 author = %author.to_hex(),
234 removed = %removed,
235 "Deletion request: removed purgatory state event(s) by coordinate"
236 );
237 }
238 }
239 _ => {
240 // Other kinds not handled
241 }
242 }
243 }
244
245 /// Remove a purgatory announcement and delete its bare repository from disk.
246 fn evict_purgatory_entry(
247 &self,
248 author: &nostr_relay_builder::prelude::PublicKey,
249 identifier: &str,
250 ) {
251 // Get repo path before removing
252 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
253 if entry.repo_path.exists() {
254 if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) {
255 tracing::warn!(
256 path = %entry.repo_path.display(),
257 error = %e,
258 "Failed to delete bare repository during deletion request processing"
259 );
260 } else {
261 tracing::info!(
262 path = %entry.repo_path.display(),
263 "Deleted bare repository for deletion-requested purgatory announcement"
264 );
265 }
266 }
267 }
268
269 self.ctx.purgatory.remove_announcement(author, identifier);
270
271 // Remove state events for this identifier only if no other owner's
272 // announcement remains in purgatory (state events are keyed by identifier alone)
273 let other_owners_remain = !self
274 .ctx
275 .purgatory
276 .get_announcements_by_identifier(identifier)
277 .is_empty();
278
279 if !other_owners_remain {
280 self.ctx.purgatory.remove_state(identifier);
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::nostr::policy::PolicyContext;
289 use crate::purgatory::Purgatory;
290 use nostr_relay_builder::prelude::*;
291 use std::collections::HashSet;
292 use std::path::PathBuf;
293 use std::sync::Arc;
294
295 fn make_context() -> PolicyContext {
296 let db = Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
297 events: true,
298 max_events: None,
299 }));
300 let purgatory = Arc::new(Purgatory::new(PathBuf::new()));
301 let config = crate::config::Config::for_testing();
302 PolicyContext::new("test.example.com", db, PathBuf::new(), purgatory, config)
303 }
304
305 fn make_announcement_event(keys: &Keys, identifier: &str) -> Event {
306 EventBuilder::new(Kind::GitRepoAnnouncement, "")
307 .tags(vec![
308 Tag::identifier(identifier),
309 Tag::custom(TagKind::custom("clone"), vec!["https://example.com/repo.git"]),
310 ])
311 .sign_with_keys(keys)
312 .unwrap()
313 }
314
315 fn add_to_purgatory(ctx: &PolicyContext, event: &Event, identifier: &str) {
316 ctx.purgatory.add_announcement(
317 event.clone(),
318 identifier.to_string(),
319 event.pubkey,
320 PathBuf::new(),
321 HashSet::new(),
322 );
323 }
324
325 #[tokio::test]
326 async fn test_deletion_by_event_id_removes_purgatory_entry() {
327 let ctx = make_context();
328 let keys = Keys::generate();
329 let identifier = "my-repo";
330
331 let announcement = make_announcement_event(&keys, identifier);
332 add_to_purgatory(&ctx, &announcement, identifier);
333
334 assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier));
335
336 // Build kind 5 deletion event referencing the announcement by event ID
337 let deletion = EventBuilder::new(Kind::EventDeletion, "")
338 .tags(vec![
339 Tag::event(announcement.id),
340 Tag::custom(TagKind::custom("k"), vec!["30617"]),
341 ])
342 .sign_with_keys(&keys)
343 .unwrap();
344
345 let policy = DeletionPolicy::new(ctx.clone());
346 let result = policy.handle(&deletion).await;
347
348 assert!(matches!(result, WritePolicyResult::Accept));
349 assert!(
350 !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier),
351 "Purgatory entry should have been removed"
352 );
353 }
354
355 #[tokio::test]
356 async fn test_deletion_by_coordinate_removes_purgatory_entry() {
357 let ctx = make_context();
358 let keys = Keys::generate();
359 let identifier = "my-repo";
360
361 let announcement = make_announcement_event(&keys, identifier);
362 add_to_purgatory(&ctx, &announcement, identifier);
363
364 assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier));
365
366 // Build kind 5 deletion event referencing the announcement by coordinate
367 let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier);
368 let deletion = EventBuilder::new(Kind::EventDeletion, "")
369 .tags(vec![
370 Tag::custom(TagKind::custom("a"), vec![coord]),
371 Tag::custom(TagKind::custom("k"), vec!["30617"]),
372 ])
373 .sign_with_keys(&keys)
374 .unwrap();
375
376 let policy = DeletionPolicy::new(ctx.clone());
377 let result = policy.handle(&deletion).await;
378
379 assert!(matches!(result, WritePolicyResult::Accept));
380 assert!(
381 !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier),
382 "Purgatory entry should have been removed"
383 );
384 }
385
386 #[tokio::test]
387 async fn test_deletion_by_wrong_author_does_not_remove() {
388 let ctx = make_context();
389 let owner_keys = Keys::generate();
390 let attacker_keys = Keys::generate();
391 let identifier = "my-repo";
392
393 let announcement = make_announcement_event(&owner_keys, identifier);
394 add_to_purgatory(&ctx, &announcement, identifier);
395
396 // Attacker tries to delete by event ID
397 let deletion = EventBuilder::new(Kind::EventDeletion, "")
398 .tags(vec![
399 Tag::event(announcement.id),
400 Tag::custom(TagKind::custom("k"), vec!["30617"]),
401 ])
402 .sign_with_keys(&attacker_keys)
403 .unwrap();
404
405 let policy = DeletionPolicy::new(ctx.clone());
406 let result = policy.handle(&deletion).await;
407
408 assert!(matches!(result, WritePolicyResult::Accept));
409 assert!(
410 ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier),
411 "Purgatory entry should NOT have been removed by wrong author"
412 );
413 }
414
415 #[tokio::test]
416 async fn test_deletion_by_coordinate_wrong_author_does_not_remove() {
417 let ctx = make_context();
418 let owner_keys = Keys::generate();
419 let attacker_keys = Keys::generate();
420 let identifier = "my-repo";
421
422 let announcement = make_announcement_event(&owner_keys, identifier);
423 add_to_purgatory(&ctx, &announcement, identifier);
424
425 // Attacker tries to delete by coordinate using owner's pubkey in coord
426 // but signs with their own key — coord pubkey != deletion author
427 let coord = format!("30617:{}:{}", owner_keys.public_key().to_hex(), identifier);
428 let deletion = EventBuilder::new(Kind::EventDeletion, "")
429 .tags(vec![
430 Tag::custom(TagKind::custom("a"), vec![coord]),
431 Tag::custom(TagKind::custom("k"), vec!["30617"]),
432 ])
433 .sign_with_keys(&attacker_keys)
434 .unwrap();
435
436 let policy = DeletionPolicy::new(ctx.clone());
437 let result = policy.handle(&deletion).await;
438
439 assert!(matches!(result, WritePolicyResult::Accept));
440 assert!(
441 ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier),
442 "Purgatory entry should NOT have been removed by wrong author"
443 );
444 }
445
446 #[tokio::test]
447 async fn test_deletion_of_nonexistent_entry_is_accepted() {
448 let ctx = make_context();
449 let keys = Keys::generate();
450
451 // No purgatory entry exists — deletion should still be accepted
452 let deletion = EventBuilder::new(Kind::EventDeletion, "")
453 .tags(vec![
454 Tag::custom(TagKind::custom("a"), vec![
455 format!("30617:{}:nonexistent", keys.public_key().to_hex())
456 ]),
457 ])
458 .sign_with_keys(&keys)
459 .unwrap();
460
461 let policy = DeletionPolicy::new(ctx.clone());
462 let result = policy.handle(&deletion).await;
463
464 assert!(matches!(result, WritePolicyResult::Accept));
465 }
466
467 #[tokio::test]
468 async fn test_deletion_by_coordinate_respects_created_at() {
469 let ctx = make_context();
470 let keys = Keys::generate();
471 let identifier = "my-repo";
472
473 // Create announcement with a future timestamp
474 let future_ts = Timestamp::now().as_secs() + 3600; // 1 hour in the future
475 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
476 .tags(vec![Tag::identifier(identifier)])
477 .custom_created_at(Timestamp::from(future_ts))
478 .sign_with_keys(&keys)
479 .unwrap();
480 add_to_purgatory(&ctx, &announcement, identifier);
481
482 // Deletion event with current timestamp (older than announcement)
483 let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier);
484 let deletion = EventBuilder::new(Kind::EventDeletion, "")
485 .tags(vec![Tag::custom(TagKind::custom("a"), vec![coord])])
486 .sign_with_keys(&keys)
487 .unwrap();
488
489 let policy = DeletionPolicy::new(ctx.clone());
490 let result = policy.handle(&deletion).await;
491
492 assert!(matches!(result, WritePolicyResult::Accept));
493 assert!(
494 ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier),
495 "Purgatory entry should NOT be removed: entry is newer than deletion request"
496 );
497 }
498}
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs
index 1566b6c..f5b981a 100644
--- a/src/nostr/policy/mod.rs
+++ b/src/nostr/policy/mod.rs
@@ -6,11 +6,13 @@
6/// - `PrEventPolicy` - PR/PR Update validation 6/// - `PrEventPolicy` - PR/PR Update validation
7/// - `RelatedEventPolicy` - Forward/backward reference checking 7/// - `RelatedEventPolicy` - Forward/backward reference checking
8mod announcement; 8mod announcement;
9mod deletion;
9mod pr_event; 10mod pr_event;
10mod related; 11mod related;
11mod state; 12mod state;
12 13
13pub use announcement::{AnnouncementPolicy, AnnouncementResult}; 14pub use announcement::{AnnouncementPolicy, AnnouncementResult};
15pub use deletion::DeletionPolicy;
14pub use pr_event::PrEventPolicy; 16pub use pr_event::PrEventPolicy;
15pub use related::{ReferenceResult, RelatedEventPolicy}; 17pub use related::{ReferenceResult, RelatedEventPolicy};
16pub use state::{StatePolicy, StateResult}; 18pub use state::{StatePolicy, StateResult};
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
index 00e09c3..072e445 100644
--- a/src/nostr/policy/pr_event.rs
+++ b/src/nostr/policy/pr_event.rs
@@ -127,6 +127,10 @@ impl PrEventPolicy {
127 .ok_or_else(|| anyhow::anyhow!("No identifier in PR event"))?; 127 .ok_or_else(|| anyhow::anyhow!("No identifier in PR event"))?;
128 128
129 // Fetch repository data 129 // Fetch repository data
130 // NOTE: Only fetch from database, NOT purgatory. Incoming PR events should
131 // only be accepted for announcements that have been promoted (validated).
132 // If the announcement is still in purgatory, the PR event should also go
133 // to purgatory and wait for the announcement to be promoted.
130 let db_repo_data = fetch_repository_data(&self.ctx.database, &identifier).await?; 134 let db_repo_data = fetch_repository_data(&self.ctx.database, &identifier).await?;
131 135
132 // Extract owner pubkey from source repo path 136 // Extract owner pubkey from source repo path
@@ -203,6 +207,10 @@ impl PrEventPolicy {
203 let identifier = parts[2]; 207 let identifier = parts[2];
204 208
205 // 2. Fetch repo data 209 // 2. Fetch repo data
210 // NOTE: Only fetch from database, NOT purgatory. Incoming PR events should
211 // only be accepted for announcements that have been promoted (validated).
212 // If the announcement is still in purgatory, the PR event should also go
213 // to purgatory and wait for the announcement to be promoted.
206 let db_repo_data = fetch_repository_data(&self.ctx.database, identifier).await?; 214 let db_repo_data = fetch_repository_data(&self.ctx.database, identifier).await?;
207 215
208 // 3. Extract list of maintainers from "a 30617:<maintainer>:<identifier>" tags 216 // 3. Extract list of maintainers from "a 30617:<maintainer>:<identifier>" tags
diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs
index 7ce87db..cfe04a7 100644
--- a/src/nostr/policy/related.rs
+++ b/src/nostr/policy/related.rs
@@ -139,6 +139,11 @@ impl RelatedEventPolicy {
139 .push((addr, pubkey, identifier)); 139 .push((addr, pubkey, identifier));
140 } 140 }
141 141
142 // NOTE: Intentionally only checks the database (promoted announcements), not purgatory.
143 // Related events should only be accepted once the repository announcement has been
144 // validated (promoted via git data). Events referencing purgatory-only repositories
145 // are correctly rejected as orphans and can be re-submitted after promotion.
146
142 // Query each kind group 147 // Query each kind group
143 for (kind, refs) in by_kind { 148 for (kind, refs) in by_kind {
144 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); 149 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
index 3411077..df743ae 100644
--- a/src/nostr/policy/state.rs
+++ b/src/nostr/policy/state.rs
@@ -1,3 +1,4 @@
1use std::collections::HashSet;
1use std::path::{Path, PathBuf}; 2use std::path::{Path, PathBuf};
2 3
3use anyhow::{Context, Result}; 4use anyhow::{Context, Result};
@@ -10,7 +11,7 @@ use nostr_relay_builder::prelude::Event;
10 11
11use super::PolicyContext; 12use super::PolicyContext;
12use crate::git; 13use crate::git;
13use crate::git::authorization::fetch_repository_data; 14use crate::git::authorization::fetch_repository_data_with_purgatory;
14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; 15use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState};
15 16
16/// Result of state policy evaluation 17/// Result of state policy evaluation
@@ -76,7 +77,13 @@ impl StatePolicy {
76 } 77 }
77 78
78 // Get all repositories and state events from db with identifier 79 // Get all repositories and state events from db with identifier
79 let db_repo_data = fetch_repository_data(&self.ctx.database, &state.identifier).await?; 80 // Include purgatory announcements for authorization
81 let db_repo_data = fetch_repository_data_with_purgatory(
82 &self.ctx.database,
83 &self.ctx.purgatory,
84 &state.identifier,
85 )
86 .await?;
80 87
81 // CRITICAL: Check if author is authorized via maintainer set 88 // CRITICAL: Check if author is authorized via maintainer set
82 // State events MUST be rejected if author is not in maintainer set of any accepted announcement 89 // State events MUST be rejected if author is not in maintainer set of any accepted announcement
@@ -139,6 +146,34 @@ impl StatePolicy {
139 "State event author authorized via maintainer set" 146 "State event author authorized via maintainer set"
140 ); 147 );
141 148
149 // Extend expiry for any purgatory announcements for this identifier.
150 //
151 // Per design doc decision #4: state event arrival extends the purgatory
152 // announcement's expiry (reset the 30-minute protocol timer). This prevents
153 // premature expiry during slow sync operations — the repo is actively receiving
154 // metadata so it should stay alive.
155 //
156 // We extend for all owners that authorized this state event, since the state
157 // event proves the repo is active regardless of which owner's announcement
158 // authorized it.
159 for owner_hex in &authorized_owners {
160 if let Ok(owner_pk) = nostr_sdk::PublicKey::from_hex(owner_hex) {
161 if self.ctx.purgatory.has_purgatory_announcement(&owner_pk, &state.identifier) {
162 self.ctx.purgatory.extend_announcement_expiry(
163 &owner_pk,
164 &state.identifier,
165 std::time::Duration::from_secs(1800),
166 );
167 tracing::debug!(
168 event_id = %event.id,
169 identifier = %state.identifier,
170 owner = %owner_hex,
171 "Extended purgatory announcement expiry due to state event arrival"
172 );
173 }
174 }
175 }
176
142 // Duplicate check in db 177 // Duplicate check in db
143 if db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)) { 178 if db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)) {
144 tracing::debug!("processed state event duplicate (in db): {}", event.id); 179 tracing::debug!("processed state event duplicate (in db): {}", event.id);
@@ -186,6 +221,42 @@ impl StatePolicy {
186 } 221 }
187 } 222 }
188 223
224 // After copying OIDs to other owner repos, promote any purgatory announcements
225 // for those repos. This handles the case where two maintainers push to the same
226 // identifier on the same relay with identical commit hashes: the second maintainer's
227 // announcement sits in purgatory, and when their state event arrives the relay copies
228 // commits from the first maintainer's repo — but without this call the announcement
229 // would stay in purgatory indefinitely.
230 let local_relay = self.ctx.get_local_relay();
231 let empty_oids: HashSet<String> = HashSet::new();
232 for announcement in &db_repo_data.announcements {
233 let target_repo_path = self.ctx.git_data_path.join(announcement.repo_path());
234 if target_repo_path != repo_with_git_data {
235 // OIDs were copied to this repo by process_state_with_git_data;
236 // check if there's a purgatory announcement waiting for it.
237 if let Err(e) = crate::git::sync::process_newly_available_git_data(
238 &target_repo_path,
239 &empty_oids,
240 &self.ctx.database,
241 local_relay.as_ref(),
242 &self.ctx.purgatory,
243 &self.ctx.git_data_path,
244 None,
245 None,
246 )
247 .await
248 {
249 tracing::warn!(
250 identifier = %state.identifier,
251 event_id = %event.id,
252 repo_path = %target_repo_path.display(),
253 error = %e,
254 "Failed to process purgatory announcements for target repo after git sync copy"
255 );
256 }
257 }
258 }
259
189 // Event will be saved and broadcast by relay builder 260 // Event will be saved and broadcast by relay builder
190 Ok(WritePolicyResult::Accept) 261 Ok(WritePolicyResult::Accept)
191 } else { 262 } else {
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index 2c278f6..bb6ff54 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -17,7 +17,7 @@ pub mod sync;
17mod types; 17mod types;
18 18
19pub use helpers::{can_apply_state, can_satisfy_state, diagnose_state_mismatch, extract_refs_from_state, get_unpushed_refs}; 19pub use helpers::{can_apply_state, can_satisfy_state, diagnose_state_mismatch, extract_refs_from_state, get_unpushed_refs};
20pub use types::{EventSource, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; 20pub use types::{AnnouncementPurgatoryEntry, EventSource, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry};
21 21
22use dashmap::DashMap; 22use dashmap::DashMap;
23use nostr_sdk::prelude::*; 23use nostr_sdk::prelude::*;
@@ -34,6 +34,13 @@ pub use sync::SyncQueueEntry;
34/// Default expiry duration for purgatory entries (30 minutes) 34/// Default expiry duration for purgatory entries (30 minutes)
35const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); 35const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800);
36 36
37/// Extended expiry for soft-expired announcements (24 hours).
38///
39/// After the initial 30-minute expiry, the bare repo is deleted but the event is
40/// retained for this additional period. This allows revival if a state event arrives
41/// late (e.g. slow sync), without permanently blocking the repository.
42const SOFT_EXPIRY_EXTENDED: Duration = Duration::from_secs(86400);
43
37/// Default delay before syncing user-submitted events (3 minutes). 44/// Default delay before syncing user-submitted events (3 minutes).
38/// This gives time for the git push to arrive after the nostr event. 45/// This gives time for the git push to arrive after the nostr event.
39const DEFAULT_SYNC_DELAY: Duration = Duration::from_secs(180); 46const DEFAULT_SYNC_DELAY: Duration = Duration::from_secs(180);
@@ -83,9 +90,35 @@ struct SerializablePrPurgatoryEntry {
83 source: types::EventSource, 90 source: types::EventSource,
84} 91}
85 92
93/// Serializable wrapper for `AnnouncementPurgatoryEntry` with time offsets.
94///
95/// Stores `Instant` fields as `Duration` offsets from the `saved_at` timestamp
96/// in `PurgatoryState`, allowing state to be persisted and restored across restarts.
97///
98/// Note: soft-expired entries (bare repo deleted) are NOT persisted — they have
99/// no git repo on disk and would be immediately cleaned up on restore anyway.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101struct SerializableAnnouncementPurgatoryEntry {
102 /// The nostr announcement event (kind 30617)
103 event: Event,
104 /// The repository identifier from the event's 'd' tag
105 identifier: String,
106 /// The owner pubkey (event author)
107 owner: PublicKey,
108 /// Path to the bare git repository (must exist on disk)
109 repo_path: PathBuf,
110 /// Relay URLs from the announcement (for sync registration)
111 relays: HashSet<String>,
112 /// Duration offset from saved_at for created_at
113 created_at_offset_secs: u64,
114 /// Duration offset from saved_at for expires_at
115 expires_at_offset_secs: u64,
116}
117
86/// Serializable purgatory state for disk persistence. 118/// Serializable purgatory state for disk persistence.
87/// 119///
88/// Contains all purgatory data needed to restore state across restarts: 120/// Contains all purgatory data needed to restore state across restarts:
121/// - Announcement events (indexed by (owner, identifier)) — non-soft-expired only
89/// - State events (indexed by identifier) 122/// - State events (indexed by identifier)
90/// - PR events (indexed by event ID) 123/// - PR events (indexed by event ID)
91/// - Expired events (to prevent re-sync loops) 124/// - Expired events (to prevent re-sync loops)
@@ -97,6 +130,10 @@ struct PurgatoryState {
97 version: u32, 130 version: u32,
98 /// When this state was saved to disk 131 /// When this state was saved to disk
99 saved_at: SystemTime, 132 saved_at: SystemTime,
133 /// Announcement events indexed by "owner_hex:identifier"
134 /// Only non-soft-expired entries are persisted (bare repo must exist).
135 #[serde(default)]
136 announcement_purgatory: HashMap<String, SerializableAnnouncementPurgatoryEntry>,
100 /// State events indexed by repository identifier 137 /// State events indexed by repository identifier
101 state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>, 138 state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>,
102 /// PR events indexed by event ID (hex string) 139 /// PR events indexed by event ID (hex string)
@@ -107,7 +144,8 @@ struct PurgatoryState {
107 144
108/// Main purgatory structure holding events awaiting git data. 145/// Main purgatory structure holding events awaiting git data.
109/// 146///
110/// Provides thread-safe concurrent access to two separate stores: 147/// Provides thread-safe concurrent access to three separate stores:
148/// - Announcements indexed by (pubkey, identifier)
111/// - State events indexed by repository identifier 149/// - State events indexed by repository identifier
112/// - PR events indexed by event ID 150/// - PR events indexed by event ID
113/// 151///
@@ -128,6 +166,10 @@ struct PurgatoryState {
128/// that we've already determined have no git data available. 166/// that we've already determined have no git data available.
129#[derive(Clone)] 167#[derive(Clone)]
130pub struct Purgatory { 168pub struct Purgatory {
169 /// Repository announcements (kind 30617) indexed by (owner pubkey, identifier).
170 /// Key: (PublicKey, String) where String is the repository identifier.
171 announcement_purgatory: Arc<DashMap<(PublicKey, String), AnnouncementPurgatoryEntry>>,
172
131 /// State events (kind 30618) indexed by repository identifier. 173 /// State events (kind 30618) indexed by repository identifier.
132 /// Multiple state events can wait for the same identifier (different maintainers). 174 /// Multiple state events can wait for the same identifier (different maintainers).
133 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>, 175 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>,
@@ -152,6 +194,7 @@ impl Purgatory {
152 /// Create a new empty purgatory. 194 /// Create a new empty purgatory.
153 pub fn new(git_data_path: impl Into<PathBuf>) -> Self { 195 pub fn new(git_data_path: impl Into<PathBuf>) -> Self {
154 Self { 196 Self {
197 announcement_purgatory: Arc::new(DashMap::new()),
155 state_events: Arc::new(DashMap::new()), 198 state_events: Arc::new(DashMap::new()),
156 pr_events: Arc::new(DashMap::new()), 199 pr_events: Arc::new(DashMap::new()),
157 sync_queue: Arc::new(DashMap::new()), 200 sync_queue: Arc::new(DashMap::new()),
@@ -576,9 +619,245 @@ impl Purgatory {
576 self.pr_events.remove(event_id); 619 self.pr_events.remove(event_id);
577 } 620 }
578 621
622 // =========================================================================
623 // Announcement Purgatory Methods
624 // =========================================================================
625
626 /// Add a repository announcement to purgatory.
627 ///
628 /// The announcement will be held until git data arrives, at which point
629 /// it will be promoted to the database and served to clients.
630 ///
631 /// # Arguments
632 /// * `event` - The announcement event (kind 30617)
633 /// * `identifier` - The repository identifier from the 'd' tag
634 /// * `owner` - The owner pubkey (event author)
635 /// * `repo_path` - Path to the bare git repository
636 /// * `relays` - Relay URLs from the announcement (for sync registration)
637 pub fn add_announcement(
638 &self,
639 event: Event,
640 identifier: String,
641 owner: PublicKey,
642 repo_path: PathBuf,
643 relays: HashSet<String>,
644 ) {
645 let now = Instant::now();
646 let entry = AnnouncementPurgatoryEntry {
647 event,
648 identifier: identifier.clone(),
649 owner,
650 repo_path,
651 relays,
652 created_at: now,
653 expires_at: now + DEFAULT_EXPIRY,
654 soft_expired: false,
655 };
656
657 let key = (owner, identifier);
658 self.announcement_purgatory.insert(key.clone(), entry);
659
660 tracing::debug!(
661 owner = %key.0,
662 identifier = %key.1,
663 "Added announcement to purgatory"
664 );
665 }
666
667 /// Find an announcement in purgatory by owner and identifier.
668 ///
669 /// # Arguments
670 /// * `owner` - The owner pubkey
671 /// * `identifier` - The repository identifier
672 ///
673 /// # Returns
674 /// The announcement entry if found, None otherwise
675 pub fn find_announcement(&self, owner: &PublicKey, identifier: &str) -> Option<AnnouncementPurgatoryEntry> {
676 let key = (*owner, identifier.to_string());
677 self.announcement_purgatory.get(&key).map(|entry| entry.clone())
678 }
679
680 /// Get all announcements in purgatory for a given identifier.
681 ///
682 /// This is used for authorization - state events and git pushes need to
683 /// check purgatory announcements for maintainer validation.
684 ///
685 /// # Arguments
686 /// * `identifier` - The repository identifier
687 ///
688 /// # Returns
689 /// Vector of announcement entries for this identifier
690 pub fn get_announcements_by_identifier(&self, identifier: &str) -> Vec<AnnouncementPurgatoryEntry> {
691 self.announcement_purgatory
692 .iter()
693 .filter(|entry| entry.key().1 == identifier)
694 .map(|entry| entry.value().clone())
695 .collect()
696 }
697
698 /// Remove an announcement from purgatory.
699 ///
700 /// # Arguments
701 /// * `owner` - The owner pubkey
702 /// * `identifier` - The repository identifier
703 pub fn remove_announcement(&self, owner: &PublicKey, identifier: &str) {
704 let key = (*owner, identifier.to_string());
705 self.announcement_purgatory.remove(&key);
706 tracing::debug!(
707 owner = %owner,
708 identifier = %identifier,
709 "Removed announcement from purgatory"
710 );
711 }
712
713 /// Promote an announcement from purgatory to active status.
714 ///
715 /// This is called when git data arrives. The announcement event is returned
716 /// so it can be saved to the database.
717 ///
718 /// # Arguments
719 /// * `owner` - The owner pubkey
720 /// * `identifier` - The repository identifier
721 ///
722 /// # Returns
723 /// The announcement event if found, None otherwise
724 pub fn promote_announcement(&self, owner: &PublicKey, identifier: &str) -> Option<Event> {
725 let key = (*owner, identifier.to_string());
726 self.announcement_purgatory.remove(&key).map(|(_, entry)| {
727 tracing::info!(
728 owner = %owner,
729 identifier = %identifier,
730 "Promoted announcement from purgatory to database"
731 );
732 entry.event
733 })
734 }
735
736 /// Check if there's an announcement in purgatory for the given owner and identifier.
737 ///
738 /// # Arguments
739 /// * `owner` - The owner pubkey
740 /// * `identifier` - The repository identifier
741 ///
742 /// # Returns
743 /// true if an announcement exists in purgatory, false otherwise
744 pub fn has_purgatory_announcement(&self, owner: &PublicKey, identifier: &str) -> bool {
745 let key = (*owner, identifier.to_string());
746 self.announcement_purgatory.contains_key(&key)
747 }
748
749 /// Extend the expiry for an announcement in purgatory.
750 ///
751 /// This is called when state events arrive for a purgatory announcement,
752 /// indicating the repository is actively receiving metadata.
753 ///
754 /// # Arguments
755 /// * `owner` - The owner pubkey
756 /// * `identifier` - The repository identifier
757 /// * `duration` - Minimum duration to guarantee from now
758 pub fn extend_announcement_expiry(&self, owner: &PublicKey, identifier: &str, duration: Duration) {
759 let key = (*owner, identifier.to_string());
760
761 // Collect revival info before taking a mutable borrow
762 let revival_info: Option<(PathBuf, bool)> = self
763 .announcement_purgatory
764 .get(&key)
765 .map(|entry| (entry.repo_path.clone(), entry.soft_expired));
766
767 if let Some(mut entry) = self.announcement_purgatory.get_mut(&key) {
768 let now = Instant::now();
769 let new_expiry = now + duration;
770 if entry.expires_at < new_expiry {
771 entry.expires_at = new_expiry;
772 }
773 // Always reset soft_expired when expiry is extended — the caller
774 // (state event or git auth) signals the repo is still active.
775 if entry.soft_expired {
776 entry.soft_expired = false;
777 }
778 }
779
780 // If the entry was soft-expired, recreate the bare repo outside the
781 // mutable borrow so we don't hold the DashMap lock during I/O.
782 if let Some((repo_path, was_soft_expired)) = revival_info {
783 if was_soft_expired {
784 if !repo_path.exists() {
785 match std::fs::create_dir_all(&repo_path) {
786 Ok(()) => {
787 // Initialise as a bare git repository
788 let status = std::process::Command::new("git")
789 .args(["init", "--bare"])
790 .arg(&repo_path)
791 .status();
792 match status {
793 Ok(s) if s.success() => {
794 tracing::info!(
795 path = %repo_path.display(),
796 owner = %owner,
797 identifier = %identifier,
798 "Recreated bare repository for revived soft-expired announcement"
799 );
800 }
801 Ok(s) => {
802 tracing::warn!(
803 path = %repo_path.display(),
804 exit_code = ?s.code(),
805 "git init --bare failed when reviving soft-expired announcement"
806 );
807 }
808 Err(e) => {
809 tracing::warn!(
810 path = %repo_path.display(),
811 error = %e,
812 "Failed to run git init --bare when reviving soft-expired announcement"
813 );
814 }
815 }
816 }
817 Err(e) => {
818 tracing::warn!(
819 path = %repo_path.display(),
820 error = %e,
821 "Failed to create directory when reviving soft-expired announcement"
822 );
823 }
824 }
825 }
826 tracing::info!(
827 owner = %owner,
828 identifier = %identifier,
829 "Revived soft-expired announcement (bare repo recreated, expiry extended)"
830 );
831 }
832 }
833 }
834
835 /// Get count of announcements in purgatory.
836 pub fn announcement_count(&self) -> usize {
837 self.announcement_purgatory.len()
838 }
839
840 /// Collect (repo_id, relay_urls) for all announcements currently in purgatory.
841 ///
842 /// Returns a vec of `(repo_id, relay_urls)` where `repo_id` is the addressable
843 /// coordinate string `"30617:{pubkey_hex}:{identifier}"`. Used by the purgatory
844 /// announcement sync timer to register StateOnly entries in `repo_sync_index`.
845 pub fn announcements_for_sync(&self) -> Vec<(String, HashSet<String>)> {
846 self.announcement_purgatory
847 .iter()
848 .map(|entry| {
849 let (owner, identifier) = entry.key();
850 let repo_id = format!("30617:{}:{}", owner.to_hex(), identifier);
851 let relays = entry.value().relays.clone();
852 (repo_id, relays)
853 })
854 .collect()
855 }
856
579 /// Get all event IDs currently stored in purgatory AND previously expired events. 857 /// Get all event IDs currently stored in purgatory AND previously expired events.
580 /// 858 ///
581 /// Returns a HashSet of all event IDs for: 859 /// Returns a HashSet of all event IDs for:
860 /// - Announcements currently held in purgatory
582 /// - State events currently held in purgatory 861 /// - State events currently held in purgatory
583 /// - PR events currently held in purgatory 862 /// - PR events currently held in purgatory
584 /// - Events that previously expired from purgatory without finding git data 863 /// - Events that previously expired from purgatory without finding git data
@@ -593,6 +872,11 @@ impl Purgatory {
593 pub fn event_ids(&self) -> HashSet<EventId> { 872 pub fn event_ids(&self) -> HashSet<EventId> {
594 let mut ids = HashSet::new(); 873 let mut ids = HashSet::new();
595 874
875 // Collect announcement event IDs
876 for entry in self.announcement_purgatory.iter() {
877 ids.insert(entry.value().event.id);
878 }
879
596 // Collect state event IDs 880 // Collect state event IDs
597 for entry in self.state_events.iter() { 881 for entry in self.state_events.iter() {
598 for state_entry in entry.value().iter() { 882 for state_entry in entry.value().iter() {
@@ -675,9 +959,86 @@ impl Purgatory {
675 /// to support migration scripts and operational monitoring. 959 /// to support migration scripts and operational monitoring.
676 /// 960 ///
677 /// # Returns 961 /// # Returns
678 /// Tuple of (num_state_removed, num_pr_removed) 962 /// Tuple of (num_announcement_removed, num_state_removed, num_pr_removed)
679 pub fn cleanup(&self) -> (usize, usize) { 963 pub fn cleanup(&self) -> (usize, usize, usize) {
680 let now = Instant::now(); 964 let now = Instant::now();
965
966 // Process expired announcements with two-phase soft expiry:
967 //
968 // Phase 1 (initial expiry, !soft_expired): Delete bare repo, set soft_expired=true,
969 // extend expiry by SOFT_EXPIRY_EXTENDED so the event is retained for revival.
970 // Phase 2 (extended expiry, soft_expired): Fully remove from purgatory.
971 //
972 // Collect entries that have passed their expires_at deadline.
973 let expired_announcements: Vec<(PublicKey, String, PathBuf, EventId, bool)> = self
974 .announcement_purgatory
975 .iter()
976 .filter(|entry| entry.value().expires_at <= now)
977 .map(|entry| {
978 let key = entry.key();
979 let v = entry.value();
980 (key.0.clone(), key.1.clone(), v.repo_path.clone(), v.event.id, v.soft_expired)
981 })
982 .collect();
983
984 let mut announcement_removed = 0;
985 for (owner, identifier, repo_path, event_id, already_soft_expired) in expired_announcements {
986 if already_soft_expired {
987 // Phase 2: fully remove
988 self.mark_expired(event_id);
989 self.announcement_purgatory.remove(&(owner.clone(), identifier.clone()));
990 announcement_removed += 1;
991 tracing::info!(
992 owner = %owner,
993 identifier = %identifier,
994 "Announcement fully expired from purgatory (soft expiry period elapsed)"
995 );
996 } else {
997 // Phase 1: soft expiry — delete bare repo, retain event.
998 //
999 // Only transition to soft_expired if the directory is gone (or never
1000 // existed). If removal fails we leave the entry untouched so the next
1001 // cleanup cycle retries the deletion automatically.
1002 let repo_gone = if repo_path.exists() {
1003 match std::fs::remove_dir_all(&repo_path) {
1004 Ok(()) => {
1005 tracing::info!(
1006 path = %repo_path.display(),
1007 owner = %owner,
1008 identifier = %identifier,
1009 "Deleted bare repository during soft expiry (event retained for revival)"
1010 );
1011 true
1012 }
1013 Err(e) => {
1014 tracing::warn!(
1015 path = %repo_path.display(),
1016 error = %e,
1017 "Failed to delete bare repository during soft expiry; will retry next cleanup cycle"
1018 );
1019 false
1020 }
1021 }
1022 } else {
1023 // Already gone (e.g. deleted externally)
1024 true
1025 };
1026
1027 if repo_gone {
1028 // Mark soft_expired and extend expiry
1029 if let Some(mut entry) = self.announcement_purgatory.get_mut(&(owner.clone(), identifier.clone())) {
1030 entry.soft_expired = true;
1031 entry.expires_at = now + SOFT_EXPIRY_EXTENDED;
1032 }
1033 tracing::debug!(
1034 owner = %owner,
1035 identifier = %identifier,
1036 "Announcement soft-expired: bare repo deleted, event retained for 24h"
1037 );
1038 }
1039 }
1040 }
1041
681 let mut state_removed = 0; 1042 let mut state_removed = 0;
682 1043
683 // Remove expired state events and mark them as expired 1044 // Remove expired state events and mark them as expired
@@ -823,17 +1184,17 @@ impl Purgatory {
823 self.pr_events.remove(&event_id_str); 1184 self.pr_events.remove(&event_id_str);
824 } 1185 }
825 1186
826 (state_removed, pr_removed) 1187 (announcement_removed, state_removed, pr_removed)
827 } 1188 }
828 1189
829 /// Remove expired entries from purgatory (legacy method). 1190 /// Remove expired entries from purgatory (legacy method).
830 /// 1191 ///
831 /// # Returns 1192 /// # Returns
832 /// Total number of entries removed (state + PR events) 1193 /// Total number of entries removed (announcement + state + PR events)
833 #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")] 1194 #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")]
834 pub fn remove_expired(&self) -> usize { 1195 pub fn remove_expired(&self) -> usize {
835 let (state, pr) = self.cleanup(); 1196 let (announcement, state, pr) = self.cleanup();
836 state + pr 1197 announcement + state + pr
837 } 1198 }
838 1199
839 /// Remove old expired event records. 1200 /// Remove old expired event records.
@@ -867,11 +1228,12 @@ impl Purgatory {
867 /// Get current count of entries in purgatory. 1228 /// Get current count of entries in purgatory.
868 /// 1229 ///
869 /// # Returns 1230 /// # Returns
870 /// Tuple of (state_event_count, pr_event_count) 1231 /// Tuple of (announcement_count, state_event_count, pr_event_count)
871 pub fn count(&self) -> (usize, usize) { 1232 pub fn count(&self) -> (usize, usize, usize) {
1233 let announcement_count = self.announcement_purgatory.len();
872 let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum(); 1234 let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum();
873 let pr_count = self.pr_events.len(); 1235 let pr_count = self.pr_events.len();
874 (state_count, pr_count) 1236 (announcement_count, state_count, pr_count)
875 } 1237 }
876 1238
877 /// Get count of expired events being tracked. 1239 /// Get count of expired events being tracked.
@@ -885,6 +1247,7 @@ impl Purgatory {
885 /// Clear all entries from purgatory (for testing). 1247 /// Clear all entries from purgatory (for testing).
886 #[cfg(test)] 1248 #[cfg(test)]
887 pub fn clear(&self) { 1249 pub fn clear(&self) {
1250 self.announcement_purgatory.clear();
888 self.state_events.clear(); 1251 self.state_events.clear();
889 self.pr_events.clear(); 1252 self.pr_events.clear();
890 self.sync_queue.clear(); 1253 self.sync_queue.clear();
@@ -949,6 +1312,34 @@ impl Purgatory {
949 let saved_at = SystemTime::now(); 1312 let saved_at = SystemTime::now();
950 let now_instant = Instant::now(); 1313 let now_instant = Instant::now();
951 1314
1315 // Convert announcement_purgatory to serializable format.
1316 // Skip soft-expired entries: their bare repos have been deleted, so they
1317 // cannot be meaningfully restored (the repo path no longer exists on disk).
1318 let mut announcement_purgatory = HashMap::new();
1319 for entry in self.announcement_purgatory.iter() {
1320 let e = entry.value();
1321 if e.soft_expired {
1322 continue;
1323 }
1324 let created_offset =
1325 persistence::instant_to_offset(e.created_at, saved_at, now_instant);
1326 let expires_offset =
1327 persistence::instant_to_offset(e.expires_at, saved_at, now_instant);
1328 let key = format!("{}:{}", e.owner.to_hex(), e.identifier);
1329 announcement_purgatory.insert(
1330 key,
1331 SerializableAnnouncementPurgatoryEntry {
1332 event: e.event.clone(),
1333 identifier: e.identifier.clone(),
1334 owner: e.owner,
1335 repo_path: e.repo_path.clone(),
1336 relays: e.relays.clone(),
1337 created_at_offset_secs: created_offset.as_secs(),
1338 expires_at_offset_secs: expires_offset.as_secs(),
1339 },
1340 );
1341 }
1342
952 // Convert state_events to serializable format 1343 // Convert state_events to serializable format
953 let mut state_events = HashMap::new(); 1344 let mut state_events = HashMap::new();
954 for entry in self.state_events.iter() { 1345 for entry in self.state_events.iter() {
@@ -1013,6 +1404,7 @@ impl Purgatory {
1013 let state = PurgatoryState { 1404 let state = PurgatoryState {
1014 version: 1, 1405 version: 1,
1015 saved_at, 1406 saved_at,
1407 announcement_purgatory,
1016 state_events, 1408 state_events,
1017 pr_events, 1409 pr_events,
1018 expired_events, 1410 expired_events,
@@ -1024,6 +1416,7 @@ impl Purgatory {
1024 1416
1025 tracing::info!( 1417 tracing::info!(
1026 path = %path.display(), 1418 path = %path.display(),
1419 announcements = state.announcement_purgatory.len(),
1027 state_events = state.state_events.len(), 1420 state_events = state.state_events.len(),
1028 pr_events = state.pr_events.len(), 1421 pr_events = state.pr_events.len(),
1029 expired_events = state.expired_events.len(), 1422 expired_events = state.expired_events.len(),
@@ -1071,6 +1464,45 @@ impl Purgatory {
1071 1464
1072 let now_instant = Instant::now(); 1465 let now_instant = Instant::now();
1073 1466
1467 // Restore announcement_purgatory.
1468 // Skip entries whose bare repo no longer exists on disk — this can happen
1469 // if the repo was deleted externally between save and restore.
1470 for (_key, e) in state.announcement_purgatory {
1471 if !e.repo_path.exists() {
1472 tracing::warn!(
1473 owner = %e.owner,
1474 identifier = %e.identifier,
1475 repo_path = %e.repo_path.display(),
1476 "Skipping announcement restore: bare repo no longer exists"
1477 );
1478 continue;
1479 }
1480 let created_at = persistence::offset_to_instant(
1481 Duration::from_secs(e.created_at_offset_secs),
1482 state.saved_at,
1483 now_instant,
1484 );
1485 let expires_at = persistence::offset_to_instant(
1486 Duration::from_secs(e.expires_at_offset_secs),
1487 state.saved_at,
1488 now_instant,
1489 );
1490 let key = (e.owner, e.identifier.clone());
1491 self.announcement_purgatory.insert(
1492 key,
1493 AnnouncementPurgatoryEntry {
1494 event: e.event,
1495 identifier: e.identifier,
1496 owner: e.owner,
1497 repo_path: e.repo_path,
1498 relays: e.relays,
1499 created_at,
1500 expires_at,
1501 soft_expired: false,
1502 },
1503 );
1504 }
1505
1074 // Restore state_events 1506 // Restore state_events
1075 for (identifier, entries) in state.state_events { 1507 for (identifier, entries) in state.state_events {
1076 let restored_entries: Vec<StatePurgatoryEntry> = entries 1508 let restored_entries: Vec<StatePurgatoryEntry> = entries
@@ -1140,6 +1572,7 @@ impl Purgatory {
1140 1572
1141 tracing::info!( 1573 tracing::info!(
1142 path = %path.display(), 1574 path = %path.display(),
1575 announcements = self.announcement_purgatory.len(),
1143 state_events = self.state_events.len(), 1576 state_events = self.state_events.len(),
1144 pr_events = self.pr_events.len(), 1577 pr_events = self.pr_events.len(),
1145 expired_events = self.expired_events.len(), 1578 expired_events = self.expired_events.len(),
@@ -1162,7 +1595,8 @@ mod tests {
1162 #[test] 1595 #[test]
1163 fn test_purgatory_creation() { 1596 fn test_purgatory_creation() {
1164 let purgatory = Purgatory::new(PathBuf::new()); 1597 let purgatory = Purgatory::new(PathBuf::new());
1165 let (state_count, pr_count) = purgatory.count(); 1598 let (announcement_count, state_count, pr_count) = purgatory.count();
1599 assert_eq!(announcement_count, 0);
1166 assert_eq!(state_count, 0); 1600 assert_eq!(state_count, 0);
1167 assert_eq!(pr_count, 0); 1601 assert_eq!(pr_count, 0);
1168 } 1602 }
@@ -1190,7 +1624,8 @@ mod tests {
1190 false, 1624 false,
1191 ); 1625 );
1192 1626
1193 let (state_count, pr_count) = purgatory.count(); 1627 let (announcement_count, state_count, pr_count) = purgatory.count();
1628 assert_eq!(announcement_count, 0);
1194 assert_eq!(state_count, 1); 1629 assert_eq!(state_count, 1);
1195 assert_eq!(pr_count, 1); 1630 assert_eq!(pr_count, 1);
1196 } 1631 }
@@ -1407,7 +1842,7 @@ fn test_cleanup_removes_expired_entries() {
1407 purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string()); 1842 purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string());
1408 1843
1409 // Verify entries are there 1844 // Verify entries are there
1410 let (state_count, pr_count) = purgatory.count(); 1845 let (_, state_count, pr_count) = purgatory.count();
1411 assert_eq!(state_count, 1); 1846 assert_eq!(state_count, 1);
1412 assert_eq!(pr_count, 2); 1847 assert_eq!(pr_count, 2);
1413 1848
@@ -1425,14 +1860,14 @@ fn test_cleanup_removes_expired_entries() {
1425 } 1860 }
1426 1861
1427 // Run cleanup 1862 // Run cleanup
1428 let (state_removed, pr_removed) = purgatory.cleanup(); 1863 let (_, state_removed, pr_removed) = purgatory.cleanup();
1429 1864
1430 // Verify counts 1865 // Verify counts
1431 assert_eq!(state_removed, 1); 1866 assert_eq!(state_removed, 1);
1432 assert_eq!(pr_removed, 2); 1867 assert_eq!(pr_removed, 2);
1433 1868
1434 // Verify entries are gone 1869 // Verify entries are gone
1435 let (state_count, pr_count) = purgatory.count(); 1870 let (_, state_count, pr_count) = purgatory.count();
1436 assert_eq!(state_count, 0); 1871 assert_eq!(state_count, 0);
1437 assert_eq!(pr_count, 0); 1872 assert_eq!(pr_count, 0);
1438} 1873}
@@ -1464,14 +1899,14 @@ fn test_cleanup_preserves_non_expired_entries() {
1464 ); 1899 );
1465 1900
1466 // Run cleanup 1901 // Run cleanup
1467 let (state_removed, pr_removed) = purgatory.cleanup(); 1902 let (_, state_removed, pr_removed) = purgatory.cleanup();
1468 1903
1469 // Nothing should be removed 1904 // Nothing should be removed
1470 assert_eq!(state_removed, 0); 1905 assert_eq!(state_removed, 0);
1471 assert_eq!(pr_removed, 0); 1906 assert_eq!(pr_removed, 0);
1472 1907
1473 // Verify entries are still there 1908 // Verify entries are still there
1474 let (state_count, pr_count) = purgatory.count(); 1909 let (_, state_count, pr_count) = purgatory.count();
1475 assert_eq!(state_count, 1); 1910 assert_eq!(state_count, 1);
1476 assert_eq!(pr_count, 1); 1911 assert_eq!(pr_count, 1);
1477} 1912}
@@ -1518,14 +1953,14 @@ fn test_cleanup_mixed_expired_and_fresh() {
1518 } 1953 }
1519 1954
1520 // Run cleanup 1955 // Run cleanup
1521 let (state_removed, pr_removed) = purgatory.cleanup(); 1956 let (_, state_removed, pr_removed) = purgatory.cleanup();
1522 1957
1523 // One of each should be removed 1958 // One of each should be removed
1524 assert_eq!(state_removed, 1); 1959 assert_eq!(state_removed, 1);
1525 assert_eq!(pr_removed, 1); 1960 assert_eq!(pr_removed, 1);
1526 1961
1527 // Verify remaining counts 1962 // Verify remaining counts
1528 let (state_count, pr_count) = purgatory.count(); 1963 let (_, state_count, pr_count) = purgatory.count();
1529 assert_eq!(state_count, 1); // One state event remains 1964 assert_eq!(state_count, 1); // One state event remains
1530 assert_eq!(pr_count, 1); // One PR event remains 1965 assert_eq!(pr_count, 1); // One PR event remains
1531} 1966}
@@ -1595,7 +2030,7 @@ fn test_expired_event_tracking() {
1595 } 2030 }
1596 2031
1597 // Run cleanup 2032 // Run cleanup
1598 let (state_removed, pr_removed) = purgatory.cleanup(); 2033 let (_, state_removed, pr_removed) = purgatory.cleanup();
1599 assert_eq!(state_removed, 1); 2034 assert_eq!(state_removed, 1);
1600 assert_eq!(pr_removed, 1); 2035 assert_eq!(pr_removed, 1);
1601 2036
@@ -1705,7 +2140,7 @@ fn test_expired_events_prevent_readdition() {
1705 } 2140 }
1706 2141
1707 // Event should NOT be re-added 2142 // Event should NOT be re-added
1708 let (state_count, _) = purgatory.count(); 2143 let (_, state_count, _) = purgatory.count();
1709 assert_eq!(state_count, 0, "Event should not be re-added to purgatory"); 2144 assert_eq!(state_count, 0, "Event should not be re-added to purgatory");
1710} 2145}
1711 2146
@@ -1724,7 +2159,7 @@ fn test_pr_placeholder_not_marked_expired() {
1724 } 2159 }
1725 2160
1726 // Run cleanup 2161 // Run cleanup
1727 let (_, pr_removed) = purgatory.cleanup(); 2162 let (_, _, pr_removed) = purgatory.cleanup();
1728 assert_eq!(pr_removed, 1); 2163 assert_eq!(pr_removed, 1);
1729 2164
1730 // Expired count should be 0 (placeholders don't have event IDs to track) 2165 // Expired count should be 0 (placeholders don't have event IDs to track)
@@ -1820,7 +2255,7 @@ async fn test_save_and_restore_state_events() {
1820 assert!(!state_file.exists()); 2255 assert!(!state_file.exists());
1821 2256
1822 // Verify state events were restored 2257 // Verify state events were restored
1823 let (state_count, _) = purgatory2.count(); 2258 let (_, state_count, _) = purgatory2.count();
1824 assert_eq!(state_count, 2); 2259 assert_eq!(state_count, 2);
1825 2260
1826 let restored_entries = purgatory2.find_state("test-repo"); 2261 let restored_entries = purgatory2.find_state("test-repo");
@@ -1877,7 +2312,7 @@ async fn test_save_and_restore_pr_events() {
1877 purgatory2.restore_from_disk(&state_file).unwrap(); 2312 purgatory2.restore_from_disk(&state_file).unwrap();
1878 2313
1879 // Verify PR event was restored 2314 // Verify PR event was restored
1880 let (_, pr_count) = purgatory2.count(); 2315 let (_, _, pr_count) = purgatory2.count();
1881 assert_eq!(pr_count, 1); 2316 assert_eq!(pr_count, 1);
1882 2317
1883 let restored_entry = purgatory2.find_pr("pr-event-id").unwrap(); 2318 let restored_entry = purgatory2.find_pr("pr-event-id").unwrap();
@@ -1906,7 +2341,7 @@ async fn test_save_and_restore_pr_placeholders() {
1906 purgatory2.restore_from_disk(&state_file).unwrap(); 2341 purgatory2.restore_from_disk(&state_file).unwrap();
1907 2342
1908 // Verify placeholder was restored 2343 // Verify placeholder was restored
1909 let (_, pr_count) = purgatory2.count(); 2344 let (_, _, pr_count) = purgatory2.count();
1910 assert_eq!(pr_count, 1); 2345 assert_eq!(pr_count, 1);
1911 2346
1912 let restored_entry = purgatory2.find_pr("placeholder-id").unwrap(); 2347 let restored_entry = purgatory2.find_pr("placeholder-id").unwrap();
@@ -1984,7 +2419,7 @@ async fn test_save_and_restore_empty_purgatory() {
1984 purgatory2.restore_from_disk(&state_file).unwrap(); 2419 purgatory2.restore_from_disk(&state_file).unwrap();
1985 2420
1986 // Verify purgatory is still empty 2421 // Verify purgatory is still empty
1987 let (state_count, pr_count) = purgatory2.count(); 2422 let (_, state_count, pr_count) = purgatory2.count();
1988 assert_eq!(state_count, 0); 2423 assert_eq!(state_count, 0);
1989 assert_eq!(pr_count, 0); 2424 assert_eq!(pr_count, 0);
1990 assert_eq!(purgatory2.expired_count(), 0); 2425 assert_eq!(purgatory2.expired_count(), 0);
@@ -2004,7 +2439,7 @@ async fn test_restore_missing_file() {
2004 assert!(result.is_err()); 2439 assert!(result.is_err());
2005 2440
2006 // Purgatory should remain empty 2441 // Purgatory should remain empty
2007 let (state_count, pr_count) = purgatory.count(); 2442 let (_, state_count, pr_count) = purgatory.count();
2008 assert_eq!(state_count, 0); 2443 assert_eq!(state_count, 0);
2009 assert_eq!(pr_count, 0); 2444 assert_eq!(pr_count, 0);
2010} 2445}
@@ -2026,7 +2461,7 @@ async fn test_restore_corrupted_json() {
2026 assert!(result.is_err()); 2461 assert!(result.is_err());
2027 2462
2028 // Purgatory should remain empty 2463 // Purgatory should remain empty
2029 let (state_count, pr_count) = purgatory.count(); 2464 let (_, state_count, pr_count) = purgatory.count();
2030 assert_eq!(state_count, 0); 2465 assert_eq!(state_count, 0);
2031 assert_eq!(pr_count, 0); 2466 assert_eq!(pr_count, 0);
2032} 2467}
@@ -2263,7 +2698,7 @@ async fn test_mixed_pr_events_and_placeholders() {
2263 purgatory2.restore_from_disk(&state_file).unwrap(); 2698 purgatory2.restore_from_disk(&state_file).unwrap();
2264 2699
2265 // Verify both were restored correctly 2700 // Verify both were restored correctly
2266 let (_, pr_count) = purgatory2.count(); 2701 let (_, _, pr_count) = purgatory2.count();
2267 assert_eq!(pr_count, 2); 2702 assert_eq!(pr_count, 2);
2268 2703
2269 // Verify PR event 2704 // Verify PR event
@@ -2310,6 +2745,141 @@ async fn test_file_cleanup_after_successful_restore() {
2310} 2745}
2311 2746
2312#[tokio::test] 2747#[tokio::test]
2748async fn test_save_and_restore_announcement_events() {
2749 use tempfile::tempdir;
2750
2751 let temp_dir = tempdir().unwrap();
2752 let state_file = temp_dir.path().join("purgatory_state.json");
2753
2754 // Create a real bare repo directory so the restore path-existence check passes
2755 let repo_dir = temp_dir.path().join("owner.git");
2756 std::fs::create_dir_all(&repo_dir).unwrap();
2757
2758 let purgatory = Purgatory::new(PathBuf::new());
2759 let keys = Keys::generate();
2760
2761 let ann_event = EventBuilder::text_note("announcement event")
2762 .sign_with_keys(&keys)
2763 .unwrap();
2764 let ann_event_id = ann_event.id;
2765
2766 let mut relays = HashSet::new();
2767 relays.insert("wss://relay.example.com".to_string());
2768
2769 purgatory.add_announcement(
2770 ann_event.clone(),
2771 "my-repo".to_string(),
2772 keys.public_key(),
2773 repo_dir.clone(),
2774 relays.clone(),
2775 );
2776
2777 // Save to disk
2778 purgatory.save_to_disk(&state_file).unwrap();
2779 assert!(state_file.exists());
2780
2781 // Create new purgatory and restore
2782 let purgatory2 = Purgatory::new(PathBuf::new());
2783 purgatory2.restore_from_disk(&state_file).unwrap();
2784
2785 // File should be deleted after restore
2786 assert!(!state_file.exists());
2787
2788 // Verify announcement was restored
2789 let (ann_count, _, _) = purgatory2.count();
2790 assert_eq!(ann_count, 1);
2791
2792 let restored = purgatory2
2793 .find_announcement(&keys.public_key(), "my-repo")
2794 .unwrap();
2795 assert_eq!(restored.event.id, ann_event_id);
2796 assert_eq!(restored.identifier, "my-repo");
2797 assert_eq!(restored.owner, keys.public_key());
2798 assert_eq!(restored.repo_path, repo_dir);
2799 assert_eq!(restored.relays, relays);
2800 assert!(!restored.soft_expired);
2801}
2802
2803#[tokio::test]
2804async fn test_soft_expired_announcements_not_persisted() {
2805 use tempfile::tempdir;
2806
2807 let temp_dir = tempdir().unwrap();
2808 let state_file = temp_dir.path().join("purgatory_state.json");
2809
2810 let repo_dir = temp_dir.path().join("owner.git");
2811 std::fs::create_dir_all(&repo_dir).unwrap();
2812
2813 let purgatory = Purgatory::new(PathBuf::new());
2814 let keys = Keys::generate();
2815
2816 let ann_event = EventBuilder::text_note("announcement event")
2817 .sign_with_keys(&keys)
2818 .unwrap();
2819
2820 purgatory.add_announcement(
2821 ann_event.clone(),
2822 "my-repo".to_string(),
2823 keys.public_key(),
2824 repo_dir.clone(),
2825 HashSet::new(),
2826 );
2827
2828 // Manually mark as soft-expired (bare repo deleted)
2829 let key = (keys.public_key(), "my-repo".to_string());
2830 if let Some(mut entry) = purgatory.announcement_purgatory.get_mut(&key) {
2831 entry.soft_expired = true;
2832 }
2833
2834 // Save to disk — soft-expired entry should be excluded
2835 purgatory.save_to_disk(&state_file).unwrap();
2836
2837 // Create new purgatory and restore
2838 let purgatory2 = Purgatory::new(PathBuf::new());
2839 purgatory2.restore_from_disk(&state_file).unwrap();
2840
2841 // Soft-expired announcement should NOT be restored
2842 let (ann_count, _, _) = purgatory2.count();
2843 assert_eq!(ann_count, 0);
2844}
2845
2846#[tokio::test]
2847async fn test_announcement_with_missing_repo_skipped_on_restore() {
2848 use tempfile::tempdir;
2849
2850 let temp_dir = tempdir().unwrap();
2851 let state_file = temp_dir.path().join("purgatory_state.json");
2852
2853 // Point to a repo path that does NOT exist
2854 let missing_repo = temp_dir.path().join("nonexistent.git");
2855
2856 let purgatory = Purgatory::new(PathBuf::new());
2857 let keys = Keys::generate();
2858
2859 let ann_event = EventBuilder::text_note("announcement event")
2860 .sign_with_keys(&keys)
2861 .unwrap();
2862
2863 purgatory.add_announcement(
2864 ann_event.clone(),
2865 "my-repo".to_string(),
2866 keys.public_key(),
2867 missing_repo.clone(),
2868 HashSet::new(),
2869 );
2870
2871 // Save to disk (repo path is serialized even though it doesn't exist)
2872 purgatory.save_to_disk(&state_file).unwrap();
2873
2874 // Create new purgatory and restore — entry should be skipped
2875 let purgatory2 = Purgatory::new(PathBuf::new());
2876 purgatory2.restore_from_disk(&state_file).unwrap();
2877
2878 let (ann_count, _, _) = purgatory2.count();
2879 assert_eq!(ann_count, 0);
2880}
2881
2882#[tokio::test]
2313async fn test_comprehensive_roundtrip() { 2883async fn test_comprehensive_roundtrip() {
2314 use nostr_sdk::{Kind, Tag, TagKind}; 2884 use nostr_sdk::{Kind, Tag, TagKind};
2315 use tempfile::tempdir; 2885 use tempfile::tempdir;
@@ -2317,10 +2887,27 @@ async fn test_comprehensive_roundtrip() {
2317 let temp_dir = tempdir().unwrap(); 2887 let temp_dir = tempdir().unwrap();
2318 let state_file = temp_dir.path().join("purgatory_state.json"); 2888 let state_file = temp_dir.path().join("purgatory_state.json");
2319 2889
2890 // Create a real bare repo directory for the announcement
2891 let repo_dir = temp_dir.path().join("owner.git");
2892 std::fs::create_dir_all(&repo_dir).unwrap();
2893
2320 let purgatory = Purgatory::new(PathBuf::new()); 2894 let purgatory = Purgatory::new(PathBuf::new());
2321 let keys1 = Keys::generate(); 2895 let keys1 = Keys::generate();
2322 let keys2 = Keys::generate(); 2896 let keys2 = Keys::generate();
2323 2897
2898 // Add announcement
2899 let ann_event = EventBuilder::text_note("announcement")
2900 .sign_with_keys(&keys1)
2901 .unwrap();
2902 let ann_event_id = ann_event.id;
2903 purgatory.add_announcement(
2904 ann_event,
2905 "repo1".to_string(),
2906 keys1.public_key(),
2907 repo_dir.clone(),
2908 HashSet::new(),
2909 );
2910
2324 // Add multiple state events 2911 // Add multiple state events
2325 let state1 = EventBuilder::text_note("state 1") 2912 let state1 = EventBuilder::text_note("state 1")
2326 .sign_with_keys(&keys1) 2913 .sign_with_keys(&keys1)
@@ -2380,7 +2967,8 @@ async fn test_comprehensive_roundtrip() {
2380 purgatory.cleanup(); 2967 purgatory.cleanup();
2381 2968
2382 // Verify initial state 2969 // Verify initial state
2383 let (state_count, pr_count) = purgatory.count(); 2970 let (ann_count, state_count, pr_count) = purgatory.count();
2971 assert_eq!(ann_count, 1); // announcement
2384 assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up) 2972 assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up)
2385 assert_eq!(pr_count, 2); // pr-1, pr-2 2973 assert_eq!(pr_count, 2); // pr-1, pr-2
2386 assert_eq!(purgatory.expired_count(), 1); // expired_event 2974 assert_eq!(purgatory.expired_count(), 1); // expired_event
@@ -2393,11 +2981,18 @@ async fn test_comprehensive_roundtrip() {
2393 purgatory2.restore_from_disk(&state_file).unwrap(); 2981 purgatory2.restore_from_disk(&state_file).unwrap();
2394 2982
2395 // Verify all data was restored correctly 2983 // Verify all data was restored correctly
2396 let (state_count2, pr_count2) = purgatory2.count(); 2984 let (ann_count2, state_count2, pr_count2) = purgatory2.count();
2985 assert_eq!(ann_count2, 1);
2397 assert_eq!(state_count2, 2); 2986 assert_eq!(state_count2, 2);
2398 assert_eq!(pr_count2, 2); 2987 assert_eq!(pr_count2, 2);
2399 assert_eq!(purgatory2.expired_count(), 1); 2988 assert_eq!(purgatory2.expired_count(), 1);
2400 2989
2990 // Verify announcement
2991 let restored_ann = purgatory2
2992 .find_announcement(&keys1.public_key(), "repo1")
2993 .unwrap();
2994 assert_eq!(restored_ann.event.id, ann_event_id);
2995
2401 // Verify state events 2996 // Verify state events
2402 assert_eq!(purgatory2.find_state("repo1").len(), 1); 2997 assert_eq!(purgatory2.find_state("repo1").len(), 1);
2403 assert_eq!(purgatory2.find_state("repo2").len(), 1); 2998 assert_eq!(purgatory2.find_state("repo2").len(), 1);
diff --git a/src/purgatory/sync/context.rs b/src/purgatory/sync/context.rs
index 904f8af..8297515 100644
--- a/src/purgatory/sync/context.rs
+++ b/src/purgatory/sync/context.rs
@@ -75,7 +75,12 @@ pub trait SyncContext: Send + Sync {
75 /// # Returns 75 /// # Returns
76 /// Set of clone URLs from PR events in purgatory for this identifier 76 /// Set of clone URLs from PR events in purgatory for this identifier
77 fn collect_pr_clone_urls(&self, identifier: &str) -> HashSet<String>; 77 fn collect_pr_clone_urls(&self, identifier: &str) -> HashSet<String>;
78 /// Get repository data (announcements, clone URLs, etc.) from the database. 78 /// Get repository data (announcements, clone URLs, etc.) from the database and purgatory.
79 ///
80 /// Checks both the database (promoted announcements) and purgatory (announcements
81 /// awaiting git data). This is necessary to obtain clone URLs when an announcement
82 /// has not yet been promoted - without purgatory data, the sync loop would have no
83 /// URLs to fetch from and the announcement could never be promoted (circular deadlock).
79 /// 84 ///
80 /// # Arguments 85 /// # Arguments
81 /// * `identifier` - The repository identifier (d-tag value) 86 /// * `identifier` - The repository identifier (d-tag value)
@@ -279,7 +284,16 @@ impl SyncContext for RealSyncContext {
279 } 284 }
280 285
281 async fn fetch_repository_data(&self, identifier: &str) -> Result<RepositoryData> { 286 async fn fetch_repository_data(&self, identifier: &str) -> Result<RepositoryData> {
282 crate::git::authorization::fetch_repository_data(&self.database, identifier).await 287 // Use the purgatory-aware variant so that clone URLs from announcements still
288 // in purgatory (not yet promoted) are available. Without this, the sync loop
289 // would find no URLs to fetch from and the announcement could never be promoted
290 // (circular deadlock: can't promote without git data, can't get git data without URLs).
291 crate::git::authorization::fetch_repository_data_with_purgatory(
292 &self.database,
293 &self.purgatory,
294 identifier,
295 )
296 .await
283 } 297 }
284 298
285 fn collect_needed_oids(&self, identifier: &str) -> HashSet<String> { 299 fn collect_needed_oids(&self, identifier: &str) -> HashSet<String> {
@@ -487,7 +501,9 @@ impl SyncContext for RealSyncContext {
487 source_repo_path: &Path, 501 source_repo_path: &Path,
488 new_oids: &HashSet<String>, 502 new_oids: &HashSet<String>,
489 ) -> Result<ProcessResult> { 503 ) -> Result<ProcessResult> {
490 // Delegate to the unified function from git::sync 504 // Delegate to the unified function from git::sync.
505 // Pass None for write_policy and rejected_events_index: the purgatory sync path
506 // already handles hot-cache re-processing via SyncManager::process_event_static.
491 let result = crate::git::sync::process_newly_available_git_data( 507 let result = crate::git::sync::process_newly_available_git_data(
492 source_repo_path, 508 source_repo_path,
493 new_oids, 509 new_oids,
@@ -495,6 +511,8 @@ impl SyncContext for RealSyncContext {
495 self.local_relay.as_ref(), 511 self.local_relay.as_ref(),
496 &self.purgatory, 512 &self.purgatory,
497 &self.git_data_path, 513 &self.git_data_path,
514 None,
515 None,
498 ) 516 )
499 .await?; 517 .await?;
500 518
diff --git a/src/purgatory/types.rs b/src/purgatory/types.rs
index e37a3e1..1af5c4e 100644
--- a/src/purgatory/types.rs
+++ b/src/purgatory/types.rs
@@ -6,6 +6,8 @@
6 6
7use nostr_sdk::prelude::*; 7use nostr_sdk::prelude::*;
8use serde::{Deserialize, Serialize}; 8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10use std::path::PathBuf;
9use std::time::Instant; 11use std::time::Instant;
10 12
11/// Source of an event entering purgatory. 13/// Source of an event entering purgatory.
@@ -143,3 +145,40 @@ pub struct PrPurgatoryEntry {
143 #[serde(default)] 145 #[serde(default)]
144 pub source: EventSource, 146 pub source: EventSource,
145} 147}
148
149/// Entry for a repository announcement (kind 30617) waiting in purgatory.
150///
151/// Announcements are held in purgatory until git data arrives, proving
152/// the repository has actual content. This prevents serving announcements
153/// for empty repositories.
154///
155/// Note: `Instant` fields cannot be serialized directly. Use the `persistence`
156/// module to convert to/from serializable wrapper types.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct AnnouncementPurgatoryEntry {
159 /// The nostr announcement event (kind 30617)
160 pub event: Event,
161
162 /// The repository identifier from the event's 'd' tag
163 pub identifier: String,
164
165 /// The owner pubkey (event author)
166 pub owner: PublicKey,
167
168 /// Path to the bare git repository
169 pub repo_path: PathBuf,
170
171 /// Relay URLs from the announcement (for sync registration)
172 pub relays: HashSet<String>,
173
174 /// When this entry was added to purgatory
175 #[serde(skip, default = "instant_now")]
176 pub created_at: Instant,
177
178 /// Expiry deadline (30 min from creation, may be extended)
179 #[serde(skip, default = "instant_now")]
180 pub expires_at: Instant,
181
182 /// Whether the bare repo has been deleted (soft expiry)
183 pub soft_expired: bool,
184}
diff --git a/src/sync/algorithms.rs b/src/sync/algorithms.rs
index 39788bc..9899abc 100644
--- a/src/sync/algorithms.rs
+++ b/src/sync/algorithms.rs
@@ -25,8 +25,10 @@ use super::{ConnectionStatus, PendingBatch, RelayState};
25/// this repo need to sync from", it's "what repos does this relay need to sync". 25/// this repo need to sync from", it's "what repos does this relay need to sync".
26#[derive(Debug, Clone, Default)] 26#[derive(Debug, Clone, Default)]
27pub struct RelaySyncNeeds { 27pub struct RelaySyncNeeds {
28 /// Repos that need to be synced from this relay 28 /// Repos that need full L2+L3 sync from this relay
29 pub repos: HashSet<String>, 29 pub repos: HashSet<String>,
30 /// Repos that only need state event sync (purgatory announcements)
31 pub state_only_repos: HashSet<String>,
30 /// Root events that need to be tracked from this relay 32 /// Root events that need to be tracked from this relay
31 pub root_events: HashSet<EventId>, 33 pub root_events: HashSet<EventId>,
32} 34}
@@ -67,8 +69,15 @@ pub fn derive_relay_targets(
67 for relay_url in &needs.relays { 69 for relay_url in &needs.relays {
68 let entry = relay_targets.entry(relay_url.clone()).or_default(); 70 let entry = relay_targets.entry(relay_url.clone()).or_default();
69 71
70 entry.repos.insert(repo_id.clone()); 72 match needs.sync_level {
71 entry.root_events.extend(needs.root_events.iter().cloned()); 73 super::SyncLevel::Full => {
74 entry.repos.insert(repo_id.clone());
75 entry.root_events.extend(needs.root_events.iter().cloned());
76 }
77 super::SyncLevel::StateOnly => {
78 entry.state_only_repos.insert(repo_id.clone());
79 }
80 }
72 } 81 }
73 } 82 }
74 83
@@ -96,7 +105,7 @@ pub fn compute_actions(
96 pending: &HashMap<String, Vec<PendingBatch>>, 105 pending: &HashMap<String, Vec<PendingBatch>>,
97 confirmed: &HashMap<String, RelayState>, 106 confirmed: &HashMap<String, RelayState>,
98) -> Vec<AddFilters> { 107) -> Vec<AddFilters> {
99 use crate::sync::filters::build_layer2_and_layer3_filters; 108 use crate::sync::filters::build_sync_level_aware_filters;
100 109
101 let mut actions = Vec::new(); 110 let mut actions = Vec::new();
102 111
@@ -140,14 +149,22 @@ pub fn compute_actions(
140 .map(|state| state.root_events.clone()) 149 .map(|state| state.root_events.clone())
141 .unwrap_or_default(); 150 .unwrap_or_default();
142 151
143 // Calculate what's NEW (not in pending, not in confirmed) 152 // Calculate what's NEW for full repos (not in pending, not in confirmed)
144 let new_repos: HashSet<String> = target_needs 153 let new_full_repos: HashSet<String> = target_needs
145 .repos 154 .repos
146 .difference(&pending_repos) 155 .difference(&pending_repos)
147 .filter(|repo| !confirmed_repos.contains(*repo)) 156 .filter(|repo| !confirmed_repos.contains(*repo))
148 .cloned() 157 .cloned()
149 .collect(); 158 .collect();
150 159
160 // Calculate what's NEW for state-only repos
161 let new_state_only_repos: HashSet<String> = target_needs
162 .state_only_repos
163 .difference(&pending_repos)
164 .filter(|repo| !confirmed_repos.contains(*repo))
165 .cloned()
166 .collect();
167
151 let new_events: HashSet<EventId> = target_needs 168 let new_events: HashSet<EventId> = target_needs
152 .root_events 169 .root_events
153 .difference(&pending_events) 170 .difference(&pending_events)
@@ -156,13 +173,23 @@ pub fn compute_actions(
156 .collect(); 173 .collect();
157 174
158 // If there's anything new, create an AddFilters action 175 // If there's anything new, create an AddFilters action
159 if !new_repos.is_empty() || !new_events.is_empty() { 176 if !new_full_repos.is_empty() || !new_state_only_repos.is_empty() || !new_events.is_empty()
160 let filters = build_layer2_and_layer3_filters(&new_repos, &new_events, None); 177 {
178 let filters = build_sync_level_aware_filters(
179 &new_full_repos,
180 &new_state_only_repos,
181 &new_events,
182 None,
183 );
184
185 // Combine all repos into pending items (pending tracking doesn't need sync level)
186 let mut all_new_repos = new_full_repos;
187 all_new_repos.extend(new_state_only_repos);
161 188
162 actions.push(AddFilters { 189 actions.push(AddFilters {
163 relay_url: relay_url.clone(), 190 relay_url: relay_url.clone(),
164 items: PendingItems { 191 items: PendingItems {
165 repos: new_repos, 192 repos: all_new_repos,
166 root_events: new_events, 193 root_events: new_events,
167 }, 194 },
168 filters, 195 filters,
@@ -204,6 +231,7 @@ mod tests {
204 ModRepoSyncNeeds { 231 ModRepoSyncNeeds {
205 relays, 232 relays,
206 root_events, 233 root_events,
234 sync_level: Default::default(),
207 }, 235 },
208 ); 236 );
209 237
@@ -229,6 +257,7 @@ mod tests {
229 ModRepoSyncNeeds { 257 ModRepoSyncNeeds {
230 relays, 258 relays,
231 root_events: HashSet::new(), 259 root_events: HashSet::new(),
260 sync_level: Default::default(),
232 }, 261 },
233 ); 262 );
234 } 263 }
@@ -252,6 +281,7 @@ mod tests {
252 ModRepoSyncNeeds { 281 ModRepoSyncNeeds {
253 relays, 282 relays,
254 root_events: HashSet::new(), 283 root_events: HashSet::new(),
284 sync_level: Default::default(),
255 }, 285 },
256 ); 286 );
257 287
@@ -285,6 +315,7 @@ mod tests {
285 ModRepoSyncNeeds { 315 ModRepoSyncNeeds {
286 relays: relays1, 316 relays: relays1,
287 root_events: root_events1, 317 root_events: root_events1,
318 sync_level: Default::default(),
288 }, 319 },
289 ); 320 );
290 321
@@ -299,6 +330,7 @@ mod tests {
299 ModRepoSyncNeeds { 330 ModRepoSyncNeeds {
300 relays: relays2, 331 relays: relays2,
301 root_events: root_events2, 332 root_events: root_events2,
333 sync_level: Default::default(),
302 }, 334 },
303 ); 335 );
304 336
@@ -332,6 +364,7 @@ mod tests {
332 "wss://relay1.com".to_string(), 364 "wss://relay1.com".to_string(),
333 RelaySyncNeeds { 365 RelaySyncNeeds {
334 repos: vec!["repo1".to_string()].into_iter().collect(), 366 repos: vec!["repo1".to_string()].into_iter().collect(),
367 state_only_repos: HashSet::new(),
335 root_events: HashSet::new(), 368 root_events: HashSet::new(),
336 }, 369 },
337 ); 370 );
@@ -366,6 +399,7 @@ mod tests {
366 "wss://relay1.com".to_string(), 399 "wss://relay1.com".to_string(),
367 RelaySyncNeeds { 400 RelaySyncNeeds {
368 repos: vec!["repo1".to_string()].into_iter().collect(), 401 repos: vec!["repo1".to_string()].into_iter().collect(),
402 state_only_repos: HashSet::new(),
369 root_events: HashSet::new(), 403 root_events: HashSet::new(),
370 }, 404 },
371 ); 405 );
@@ -389,6 +423,7 @@ mod tests {
389 "wss://relay1.com".to_string(), 423 "wss://relay1.com".to_string(),
390 RelaySyncNeeds { 424 RelaySyncNeeds {
391 repos: vec!["repo1".to_string()].into_iter().collect(), 425 repos: vec!["repo1".to_string()].into_iter().collect(),
426 state_only_repos: HashSet::new(),
392 root_events: HashSet::new(), 427 root_events: HashSet::new(),
393 }, 428 },
394 ); 429 );
@@ -428,6 +463,7 @@ mod tests {
428 "wss://relay1.com".to_string(), 463 "wss://relay1.com".to_string(),
429 RelaySyncNeeds { 464 RelaySyncNeeds {
430 repos: vec!["repo1".to_string()].into_iter().collect(), 465 repos: vec!["repo1".to_string()].into_iter().collect(),
466 state_only_repos: HashSet::new(),
431 root_events: HashSet::new(), 467 root_events: HashSet::new(),
432 }, 468 },
433 ); 469 );
@@ -465,6 +501,7 @@ mod tests {
465 "wss://relay1.com".to_string(), 501 "wss://relay1.com".to_string(),
466 RelaySyncNeeds { 502 RelaySyncNeeds {
467 repos: vec!["repo1".to_string()].into_iter().collect(), 503 repos: vec!["repo1".to_string()].into_iter().collect(),
504 state_only_repos: HashSet::new(),
468 root_events: HashSet::new(), 505 root_events: HashSet::new(),
469 }, 506 },
470 ); 507 );
@@ -510,6 +547,7 @@ mod tests {
510 ] 547 ]
511 .into_iter() 548 .into_iter()
512 .collect(), 549 .collect(),
550 state_only_repos: HashSet::new(),
513 root_events: HashSet::new(), 551 root_events: HashSet::new(),
514 }, 552 },
515 ); 553 );
@@ -572,6 +610,7 @@ mod tests {
572 "wss://relay1.com".to_string(), 610 "wss://relay1.com".to_string(),
573 RelaySyncNeeds { 611 RelaySyncNeeds {
574 repos: HashSet::new(), 612 repos: HashSet::new(),
613 state_only_repos: HashSet::new(),
575 root_events: vec![event_id].into_iter().collect(), 614 root_events: vec![event_id].into_iter().collect(),
576 }, 615 },
577 ); 616 );
@@ -599,6 +638,7 @@ mod tests {
599 "wss://new-relay.com".to_string(), 638 "wss://new-relay.com".to_string(),
600 RelaySyncNeeds { 639 RelaySyncNeeds {
601 repos: vec!["repo1".to_string()].into_iter().collect(), 640 repos: vec!["repo1".to_string()].into_iter().collect(),
641 state_only_repos: HashSet::new(),
602 root_events: HashSet::new(), 642 root_events: HashSet::new(),
603 }, 643 },
604 ); 644 );
diff --git a/src/sync/filters.rs b/src/sync/filters.rs
index 3592489..1215e81 100644
--- a/src/sync/filters.rs
+++ b/src/sync/filters.rs
@@ -245,6 +245,37 @@ pub fn build_layer2_and_layer3_filters(
245 filters 245 filters
246} 246}
247 247
248/// Builds filters respecting SyncLevel for each repo
249///
250/// StateOnly repos only get state event filters (kind 30618).
251/// Full repos get all L2/L3 filters (state + repo-tagging + root event).
252///
253/// # Arguments
254/// * `full_repos` - Repos needing full L2+L3 sync
255/// * `state_only_repos` - Repos needing only state event sync (purgatory)
256/// * `root_events` - Root event IDs (only used for Full repos)
257/// * `since` - Optional timestamp for incremental sync
258pub fn build_sync_level_aware_filters(
259 full_repos: &HashSet<String>,
260 state_only_repos: &HashSet<String>,
261 root_events: &HashSet<EventId>,
262 since: Option<Timestamp>,
263) -> Vec<Filter> {
264 let mut filters = Vec::new();
265
266 // All repos (both Full and StateOnly) need state event filters
267 let all_repos: HashSet<String> = full_repos.union(state_only_repos).cloned().collect();
268 filters.extend(state_event_filters_for_our_repos(&all_repos, since));
269
270 // Only Full repos get repo-tagging and root event filters
271 if !full_repos.is_empty() {
272 filters.extend(tagged_one_of_our_repo_event_filters(full_repos, since));
273 }
274 filters.extend(tagged_one_of_our_root_event_filters(root_events, since));
275
276 filters
277}
278
248#[cfg(test)] 279#[cfg(test)]
249mod tests { 280mod tests {
250 use super::*; 281 use super::*;
diff --git a/src/sync/mod.rs b/src/sync/mod.rs
index d6634ff..cd62380 100644
--- a/src/sync/mod.rs
+++ b/src/sync/mod.rs
@@ -85,6 +85,19 @@ use rejected_index::RejectedEventsIndex;
85// Supporting Data Structures 85// Supporting Data Structures
86// ============================================================================= 86// =============================================================================
87 87
88/// Level of sync needed for a repository
89///
90/// Purgatory announcements only need state events synced (to validate git data).
91/// Promoted repos need full L2/L3 sync (patches, issues, PRs, etc.).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum SyncLevel {
94 /// Full L2 + L3 sync (promoted repos with git data)
95 #[default]
96 Full,
97 /// Only state events (kind 30618) - for purgatory announcements
98 StateOnly,
99}
100
88/// What repos and root events need to be synced 101/// What repos and root events need to be synced
89#[derive(Debug, Clone, Default)] 102#[derive(Debug, Clone, Default)]
90pub struct RepoSyncNeeds { 103pub struct RepoSyncNeeds {
@@ -92,6 +105,8 @@ pub struct RepoSyncNeeds {
92 pub relays: HashSet<String>, 105 pub relays: HashSet<String>,
93 /// Root event IDs - 1617/1618/1621 - that reference this repo 106 /// Root event IDs - 1617/1618/1621 - that reference this repo
94 pub root_events: HashSet<EventId>, 107 pub root_events: HashSet<EventId>,
108 /// Sync level - StateOnly for purgatory, Full for promoted repos
109 pub sync_level: SyncLevel,
95} 110}
96 111
97/// Connection status for a relay 112/// Connection status for a relay
@@ -382,6 +397,40 @@ async fn run_daily_timer(
382 } 397 }
383} 398}
384 399
400/// Background task that periodically syncs purgatory announcements into repo_sync_index.
401///
402/// Runs every 5 seconds by default (200ms when `NGIT_TEST=1`).
403/// For each announcement currently in purgatory, ensures there is a `StateOnly` entry in
404/// `repo_sync_index`. New entries trigger `handle_new_sync_filters` which connects to the
405/// relay URLs listed in the announcement and subscribes to state events (kind 30618).
406///
407/// This is the sole registration path for purgatory announcements:
408/// - Sync-path announcements: registered here within one interval of arriving.
409/// - User-submitted purgatory announcements: the SelfSubscriber never sees them
410/// (they're rejected from DB), so this timer is the only registration path.
411async fn run_purgatory_announcement_sync(
412 sync_manager: Arc<Mutex<SyncManager>>,
413 mut shutdown_rx: broadcast::Receiver<()>,
414) {
415 let interval = if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
416 Duration::from_millis(200)
417 } else {
418 Duration::from_secs(5)
419 };
420 loop {
421 tokio::select! {
422 _ = tokio::time::sleep(interval) => {
423 let mut manager = sync_manager.lock().await;
424 manager.sync_purgatory_announcements_to_index().await;
425 }
426 _ = shutdown_rx.recv() => {
427 tracing::debug!("Purgatory announcement sync timer received shutdown signal");
428 break;
429 }
430 }
431 }
432}
433
385// Combined Health and Metrics Checker 434// Combined Health and Metrics Checker
386 435
387/// Background task for cleaning up expired entries from the rejected events index 436/// Background task for cleaning up expired entries from the rejected events index
@@ -936,9 +985,29 @@ impl SyncManager {
936 985
937 // Create REQ+EOSE subscriptions using original semantic filters 986 // Create REQ+EOSE subscriptions using original semantic filters
938 // This queries by kind/author/tags instead of by ID, which may 987 // This queries by kind/author/tags instead of by ID, which may
939 // succeed even when ID-based queries fail 988 // succeed even when ID-based queries fail.
940 let fallback_filters = filters::build_layer2_and_layer3_filters( 989 // Split batch_repos by SyncLevel to avoid sending Layer 2 filters
941 &batch_repos, 990 // (#a/#A/#q) for StateOnly (purgatory) repos - those PRs would be
991 // rejected as orphan and then silently dropped by nostr-sdk deduplication.
992 let (full_repos, state_only_repos) = {
993 let repo_index = self.repo_sync_index.read().await;
994 let mut full = HashSet::new();
995 let mut state_only = HashSet::new();
996 for repo_ref in &batch_repos {
997 match repo_index.get(repo_ref).map(|n| n.sync_level) {
998 Some(SyncLevel::StateOnly) => {
999 state_only.insert(repo_ref.clone());
1000 }
1001 _ => {
1002 full.insert(repo_ref.clone());
1003 }
1004 }
1005 }
1006 (full, state_only)
1007 };
1008 let fallback_filters = filters::build_sync_level_aware_filters(
1009 &full_repos,
1010 &state_only_repos,
942 &batch_root_events, 1011 &batch_root_events,
943 None, 1012 None,
944 ); 1013 );
@@ -1272,7 +1341,7 @@ impl SyncManager {
1272 /// to be batched and create Layer 2/3 filters before we mark sync complete. 1341 /// to be batched and create Layer 2/3 filters before we mark sync complete.
1273 /// 1342 ///
1274 /// The 6-second delay is based on: 1343 /// The 6-second delay is based on:
1275 /// - Self-subscriber batch window: 5 seconds (configurable via NGIT_SYNC_BATCH_WINDOW_MS) 1344 /// - Self-subscriber batch window: 5 seconds (200ms when `NGIT_TEST=1`)
1276 /// - Buffer for processing: 1 second 1345 /// - Buffer for processing: 1 second
1277 /// 1346 ///
1278 /// Called after each batch is confirmed to detect completion. 1347 /// Called after each batch is confirmed to detect completion.
@@ -1486,7 +1555,17 @@ impl SyncManager {
1486 run_rejected_index_cleanup(cleanup_manager, cleanup_shutdown).await; 1555 run_rejected_index_cleanup(cleanup_manager, cleanup_shutdown).await;
1487 }); 1556 });
1488 1557
1489 // 11. Main loop - handle actions from self-subscriber, disconnect, EOSE, and connect notifications 1558 // 11. Spawn purgatory announcement sync timer (every 5s)
1559 // Ensures purgatory announcements (including user-submitted ones that never
1560 // touch the DB) are registered in repo_sync_index as StateOnly so that
1561 // state event subscriptions are established on their listed relay URLs.
1562 let purgatory_sync_manager = Arc::clone(&sync_manager);
1563 let purgatory_sync_shutdown = shutdown_tx.subscribe();
1564 tokio::spawn(async move {
1565 run_purgatory_announcement_sync(purgatory_sync_manager, purgatory_sync_shutdown).await;
1566 });
1567
1568 // 12. Main loop - handle actions from self-subscriber, disconnect, EOSE, and connect notifications
1490 loop { 1569 loop {
1491 // Wait for an event without holding the lock 1570 // Wait for an event without holding the lock
1492 tokio::select! { 1571 tokio::select! {
@@ -1719,6 +1798,10 @@ impl SyncManager {
1719 1798
1720 // For sync-triggered events that go to purgatory, trigger immediate sync 1799 // For sync-triggered events that go to purgatory, trigger immediate sync
1721 // (instead of the default 3-minute delay for user-submitted events) 1800 // (instead of the default 3-minute delay for user-submitted events)
1801 //
1802 // Note: announcement events (kind 30617) are registered in repo_sync_index
1803 // by the purgatory announcement sync timer (run_purgatory_announcement_sync)
1804 // rather than inline here.
1722 if result == ProcessResult::Purgatory { 1805 if result == ProcessResult::Purgatory {
1723 // State events (kind 30618) - extract identifier and trigger immediate sync 1806 // State events (kind 30618) - extract identifier and trigger immediate sync
1724 if event.kind.as_u16() == 30618 { 1807 if event.kind.as_u16() == 30618 {
@@ -2303,6 +2386,80 @@ impl SyncManager {
2303 } 2386 }
2304 } 2387 }
2305 2388
2389 /// Sync purgatory announcements into repo_sync_index as StateOnly entries.
2390 ///
2391 /// Called periodically by the purgatory announcement sync timer (every 5s).
2392 /// For each announcement currently in purgatory, ensures a `StateOnly` entry
2393 /// exists in `repo_sync_index`. New entries are then picked up by
2394 /// `handle_new_sync_filters` which connects to listed relay URLs and subscribes
2395 /// to state events for that repo.
2396 ///
2397 /// Idempotent: existing entries are not downgraded (a promoted Full entry stays Full).
2398 async fn sync_purgatory_announcements_to_index(&mut self) {
2399 use crate::sync::algorithms::{compute_actions, derive_relay_targets};
2400
2401 // Collect all purgatory announcements (snapshot - no async holds)
2402 let announcements = self.purgatory.announcements_for_sync();
2403
2404 if announcements.is_empty() {
2405 return;
2406 }
2407
2408 // Register any new entries in repo_sync_index as StateOnly
2409 let mut new_relay_urls: std::collections::HashSet<String> = std::collections::HashSet::new();
2410 {
2411 let mut index = self.repo_sync_index.write().await;
2412 for (repo_id, relays) in &announcements {
2413 let entry = index.entry(repo_id.clone()).or_insert_with(|| {
2414 tracing::debug!(
2415 repo_id = %repo_id,
2416 "Registering purgatory announcement in repo_sync_index as StateOnly"
2417 );
2418 RepoSyncNeeds {
2419 relays: std::collections::HashSet::new(),
2420 root_events: std::collections::HashSet::new(),
2421 sync_level: SyncLevel::StateOnly,
2422 }
2423 });
2424 // Don't downgrade an already-Full entry
2425 // Add any new relay URLs
2426 for relay in relays {
2427 if entry.relays.insert(relay.clone()) {
2428 new_relay_urls.insert(relay.clone());
2429 }
2430 }
2431 }
2432 }
2433
2434 if new_relay_urls.is_empty() {
2435 return;
2436 }
2437
2438 // For any relay URLs that are new, compute and send AddFilters actions
2439 let all_targets = {
2440 let repo_index = self.repo_sync_index.read().await;
2441 derive_relay_targets(&repo_index)
2442 };
2443
2444 let actions = {
2445 let pending_index = self.pending_sync_index.read().await;
2446 let relay_index = self.relay_sync_index.read().await;
2447 compute_actions(&all_targets, &pending_index, &relay_index)
2448 };
2449
2450 for action in actions {
2451 // Only act on relays that have new URLs (avoids redundant work)
2452 if new_relay_urls.contains(&action.relay_url) {
2453 tracing::info!(
2454 relay = %action.relay_url,
2455 repos = action.items.repos.len(),
2456 "Purgatory sync timer: connecting to new relay from purgatory announcement"
2457 );
2458 self.handle_new_sync_filters(action).await;
2459 }
2460 }
2461 }
2462
2306 /// Handle a relay disconnection 2463 /// Handle a relay disconnection
2307 /// 2464 ///
2308 /// This method is called when the event loop terminates and sends a disconnect notification. 2465 /// This method is called when the event loop terminates and sends a disconnect notification.
diff --git a/src/sync/self_subscriber.rs b/src/sync/self_subscriber.rs
index 86e4583..4d69c9a 100644
--- a/src/sync/self_subscriber.rs
+++ b/src/sync/self_subscriber.rs
@@ -18,7 +18,7 @@ use tokio::sync::{broadcast, mpsc};
18 18
19use crate::nostr::builder::SharedDatabase; 19use crate::nostr::builder::SharedDatabase;
20 20
21use super::{AddFilters, RepoSyncIndex, RepoSyncNeeds}; 21use super::{AddFilters, RepoSyncIndex, RepoSyncNeeds, SyncLevel};
22 22
23// ============================================================================= 23// =============================================================================
24// LoopControl - Result of notification processing 24// LoopControl - Result of notification processing
@@ -60,6 +60,7 @@ impl PendingUpdates {
60 let entry = self.repos.entry(repo_id).or_insert_with(|| RepoSyncNeeds { 60 let entry = self.repos.entry(repo_id).or_insert_with(|| RepoSyncNeeds {
61 relays: HashSet::new(), 61 relays: HashSet::new(),
62 root_events: HashSet::new(), 62 root_events: HashSet::new(),
63 sync_level: SyncLevel::Full,
63 }); 64 });
64 entry.relays.extend(relays); 65 entry.relays.extend(relays);
65 entry.root_events.extend(root_events); 66 entry.root_events.extend(root_events);
@@ -132,14 +133,14 @@ impl SelfSubscriber {
132 133
133 /// Get batch window from environment or use default 134 /// Get batch window from environment or use default
134 /// 135 ///
135 /// Reads `NGIT_SYNC_BATCH_WINDOW_MS` environment variable. 136 /// When `NGIT_TEST=1` is set, uses 200ms for faster test execution.
136 /// Default: 5000ms (5 seconds) 137 /// Default: 5000ms (5 seconds)
137 fn get_batch_window() -> Duration { 138 fn get_batch_window() -> Duration {
138 std::env::var("NGIT_SYNC_BATCH_WINDOW_MS") 139 if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
139 .ok() 140 Duration::from_millis(200)
140 .and_then(|s| s.parse::<u64>().ok()) 141 } else {
141 .map(Duration::from_millis) 142 Duration::from_millis(5000)
142 .unwrap_or(Duration::from_millis(5000)) 143 }
143 } 144 }
144 145
145 /// Load existing events from database on startup 146 /// Load existing events from database on startup
@@ -197,6 +198,7 @@ impl SelfSubscriber {
197 .or_insert_with(|| RepoSyncNeeds { 198 .or_insert_with(|| RepoSyncNeeds {
198 relays: HashSet::new(), 199 relays: HashSet::new(),
199 root_events: HashSet::new(), 200 root_events: HashSet::new(),
201 sync_level: SyncLevel::StateOnly,
200 }); 202 });
201 entry.relays.extend(needs.relays.clone()); 203 entry.relays.extend(needs.relays.clone());
202 } 204 }
@@ -570,7 +572,12 @@ impl SelfSubscriber {
570 .or_insert_with(|| RepoSyncNeeds { 572 .or_insert_with(|| RepoSyncNeeds {
571 relays: HashSet::new(), 573 relays: HashSet::new(),
572 root_events: HashSet::new(), 574 root_events: HashSet::new(),
575 sync_level: SyncLevel::Full,
573 }); 576 });
577 // Upgrade sync_level to Full - this handles the case where the entry
578 // already exists as StateOnly (purgatory announcement) and is now being
579 // promoted (git data arrived and the event was broadcast via notify_event).
580 entry.sync_level = SyncLevel::Full;
574 entry.relays.extend(needs.relays); 581 entry.relays.extend(needs.relays);
575 entry.root_events.extend(needs.root_events); 582 entry.root_events.extend(needs.root_events);
576 583
@@ -594,21 +601,26 @@ impl SelfSubscriber {
594 continue; 601 continue;
595 } 602 }
596 603
597 // Build filters for these repos 604 // Build filters for these repos (sync-level-aware)
598 let filters = crate::sync::filters::build_layer2_and_layer3_filters( 605 let filters = crate::sync::filters::build_sync_level_aware_filters(
599 &needs.repos, 606 &needs.repos,
607 &needs.state_only_repos,
600 &needs.root_events, 608 &needs.root_events,
601 None, 609 None,
602 ); 610 );
603 611
604 // Log before moving values 612 // Log before moving values
605 let repo_count = needs.repos.len(); 613 let repo_count = needs.repos.len() + needs.state_only_repos.len();
606 let event_count = needs.root_events.len(); 614 let event_count = needs.root_events.len();
607 615
616 // Combine all repos into pending items
617 let mut all_repos = needs.repos;
618 all_repos.extend(needs.state_only_repos);
619
608 let action = AddFilters { 620 let action = AddFilters {
609 relay_url: relay_url.clone(), 621 relay_url: relay_url.clone(),
610 items: crate::sync::PendingItems { 622 items: crate::sync::PendingItems {
611 repos: needs.repos, 623 repos: all_repos,
612 root_events: needs.root_events, 624 root_events: needs.root_events,
613 }, 625 },
614 filters, 626 filters,
diff --git a/tests/archive_grasp_services.rs b/tests/archive_grasp_services.rs
index a47fc55..9f13d2a 100644
--- a/tests/archive_grasp_services.rs
+++ b/tests/archive_grasp_services.rs
@@ -29,7 +29,11 @@
29 29
30mod common; 30mod common;
31 31
32use common::TestRelay; 32use common::{
33 check_ref_at_commit, create_repo_announcement, create_state_event,
34 create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection,
35 CommitVariant, TestRelay,
36};
33use nostr_sdk::prelude::*; 37use nostr_sdk::prelude::*;
34use std::path::PathBuf; 38use std::path::PathBuf;
35use std::process::{Child, Command, Stdio}; 39use std::process::{Child, Command, Stdio};
@@ -376,3 +380,222 @@ async fn test_archive_multiple_grasp_services() {
376 let _ = process.kill(); 380 let _ = process.kill();
377 let _ = process.wait(); 381 let _ = process.wait();
378} 382}
383
384/// Test that archive_read_only mode creates bare git repositories and syncs data
385/// via relay-to-relay sync (purgatory sync infrastructure).
386///
387/// Scenario:
388/// 1. Start source relay with full repository (announcement + state + git data)
389/// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
390/// 3. Archive relay syncs announcement and state events from source
391/// 4. State events trigger purgatory sync which fetches git data from source's clone URL
392/// 5. Verify bare repository is created and git data is synced
393/// 6. Verify git pushes are rejected (read-only mode)
394#[tokio::test]
395async fn test_archive_read_only_creates_bare_repo() {
396 // 1. Start source relay
397 let source_relay = TestRelay::start().await;
398 let keys = Keys::generate();
399 let identifier = "archive-test-repo";
400
401 // Pre-allocate archive relay port so we can include it in announcement
402 let archive_port = TestRelay::find_free_port();
403 let archive_domain = format!("127.0.0.1:{}", archive_port);
404
405 // 2. Create test repository locally with deterministic commit
406 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
407 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
408 .expect("Failed to create test repo");
409
410 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
411
412 // 3. Create and send announcement listing BOTH relays
413 // This ensures the archive relay will accept the state event when it syncs
414 let announcement = create_repo_announcement(
415 &keys,
416 &[&source_relay.domain(), &archive_domain],
417 identifier,
418 );
419
420 let source_client = Client::new(keys.clone());
421 source_client
422 .add_relay(source_relay.url())
423 .await
424 .expect("Failed to add source relay");
425 source_client.connect().await;
426
427 // Wait for connection
428 tokio::time::sleep(Duration::from_millis(500)).await;
429
430 // Send announcement to source relay
431 source_client
432 .send_event(&announcement)
433 .await
434 .expect("Failed to send announcement to source");
435
436 tokio::time::sleep(Duration::from_millis(200)).await;
437
438 // 4. Create and send state event
439 let clone_urls = [
440 format!(
441 "http://{}/{}/{}.git",
442 source_relay.domain(),
443 npub,
444 identifier
445 ),
446 format!("http://{}/{}/{}.git", archive_domain, npub, identifier),
447 ];
448 let relay_urls = [
449 source_relay.url().to_string(),
450 format!("ws://{}", archive_domain),
451 ];
452
453 let state_event = create_state_event(
454 &keys,
455 identifier,
456 &[("main", &commit_hash)],
457 &[],
458 &[&clone_urls[0], &clone_urls[1]],
459 &[&relay_urls[0], &relay_urls[1]],
460 )
461 .expect("Failed to create state event");
462
463 let state_event_id = state_event.id;
464
465 // Send state event to source relay (goes to purgatory - no git data yet)
466 source_client
467 .send_event(&state_event)
468 .await
469 .expect("Failed to send state event to source");
470
471 tokio::time::sleep(Duration::from_millis(200)).await;
472
473 // 5. Push git data to source relay
474 // The state event in purgatory authorizes this push
475 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
476 .expect("Push to source should succeed");
477
478 // After push, state event should be released from purgatory on source relay
479 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
480 .await
481 .expect("State event should be served on source relay after push");
482
483 // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
484 let archive_relay = TestRelay::start_with_archive_and_sync(
485 archive_port,
486 Some(source_relay.url().to_string()),
487 false, // negentropy enabled
488 true, // archive_all
489 true, // archive_read_only
490 )
491 .await;
492
493 // Wait for sync connection to establish
494 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
495 .await
496 .expect("Sync connection should establish");
497
498 // 7. Wait for state event to be released on archive relay
499 // The sync should:
500 // a) Fetch the announcement and state event from source relay
501 // b) Accept announcement (creates bare repo structure) - via archive mode
502 // c) Put state event in purgatory (git data missing on archive relay)
503 // d) Fetch git data from source relay's clone URL
504 // e) Release the state event from purgatory
505
506 let found = wait_for_event_served(
507 archive_relay.url(),
508 &state_event_id,
509 Duration::from_secs(30), // Allow time for sync + git fetch
510 )
511 .await;
512
513 assert!(
514 found.is_ok(),
515 "State event should be served after sync fetches git data: {:?}",
516 found.err()
517 );
518
519 // 8. Verify bare repository was created
520 let repo_path = archive_relay
521 .git_data_path()
522 .join(format!("{}/{}.git", npub, identifier));
523
524 assert!(
525 repo_path.exists(),
526 "Bare repository should be created at {:?} for archive announcement",
527 repo_path
528 );
529
530 // 9. Verify it's a bare repository (check for config file with bare = true)
531 let config_path = repo_path.join("config");
532 assert!(
533 config_path.exists(),
534 "Git config should exist at {:?}",
535 config_path
536 );
537
538 let config_content = tokio::fs::read_to_string(&config_path)
539 .await
540 .expect("Should read git config");
541 assert!(
542 config_content.contains("bare = true"),
543 "Repository at {:?} should be bare (config should contain 'bare = true')",
544 repo_path
545 );
546
547 // 10. Verify refs are correct on archive relay
548 let ref_correct = check_ref_at_commit(
549 &archive_domain,
550 &npub,
551 identifier,
552 "refs/heads/main",
553 &commit_hash,
554 )
555 .await
556 .expect("Failed to check ref");
557
558 assert!(ref_correct, "main branch should point to correct commit");
559
560 // 11. Verify git pushes are rejected (read-only mode)
561 // Create a new commit in the source repo
562 tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content")
563 .await
564 .expect("Failed to write new file");
565
566 let output = tokio::process::Command::new("git")
567 .args(["add", "."])
568 .current_dir(temp_dir.path())
569 .output()
570 .await
571 .expect("Failed to git add");
572 assert!(output.status.success());
573
574 let output = tokio::process::Command::new("git")
575 .args(["commit", "-m", "New commit for push test"])
576 .current_dir(temp_dir.path())
577 .output()
578 .await
579 .expect("Failed to git commit");
580 assert!(output.status.success());
581
582 // Try to push to archive relay (should fail in read-only mode)
583 let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier);
584 let output = tokio::process::Command::new("git")
585 .args(["push", &push_url, "main"])
586 .current_dir(temp_dir.path())
587 .output()
588 .await
589 .expect("Failed to run git push");
590
591 assert!(
592 !output.status.success(),
593 "Git push should be rejected in archive_read_only mode. stderr: {}",
594 String::from_utf8_lossy(&output.stderr)
595 );
596
597 // Cleanup
598 source_client.disconnect().await;
599 archive_relay.stop().await;
600 source_relay.stop().await;
601}
diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs
deleted file mode 100644
index be6959b..0000000
--- a/tests/archive_read_only.rs
+++ /dev/null
@@ -1,368 +0,0 @@
1//! Archive Read-Only Mode Integration Tests
2//!
3//! Tests that verify archive_read_only mode behavior:
4//! - Bare git repositories are created for announcements
5//! - Git data is synced via relay-to-relay sync (purgatory sync)
6//! - Git pushes are rejected (read-only mode)
7//!
8//! # Test Strategy
9//!
10//! These tests verify the GRASP-05 archive mode with read_only flag:
11//! 1. Source relay has full repository (announcement + state events + git data)
12//! 2. Archive relay syncs from source relay (relay-to-relay sync)
13//! 3. State events trigger purgatory sync which fetches git data
14//! 4. Git data is validated against Nostr state events
15//! 5. Git pushes are rejected (read-only enforcement)
16//!
17//! # Security Model
18//!
19//! Archive mode uses the existing purgatory sync infrastructure to ensure:
20//! - Git data is validated against Nostr state events
21//! - "Naughty git servers" can't provide incorrect state
22//! - Same security guarantees as normal relay operation
23//!
24//! # Running Tests
25//!
26//! ```bash
27//! # Run all archive read-only tests
28//! cargo test --test archive_read_only
29//!
30//! # Run specific test
31//! cargo test --test archive_read_only test_archive_read_only_creates_bare_repo
32//!
33//! # With output for debugging
34//! cargo test --test archive_read_only -- --nocapture
35//! ```
36
37mod common;
38
39use common::{
40 check_ref_at_commit, create_repo_announcement, create_state_event,
41 create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection,
42 CommitVariant, TestRelay,
43};
44use nostr_sdk::prelude::*;
45use std::time::Duration;
46
47/// Test that archive_read_only mode creates bare git repositories and syncs data
48/// via relay-to-relay sync (purgatory sync infrastructure).
49///
50/// Scenario:
51/// 1. Start source relay with full repository (announcement + state + git data)
52/// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
53/// 3. Archive relay syncs announcement and state events from source
54/// 4. State events trigger purgatory sync which fetches git data from source's clone URL
55/// 5. Verify bare repository is created and git data is synced
56/// 6. Verify git pushes are rejected (read-only mode)
57#[tokio::test]
58async fn test_archive_read_only_creates_bare_repo() {
59 // 1. Start source relay
60 let source_relay = TestRelay::start().await;
61 let keys = Keys::generate();
62 let identifier = "archive-test-repo";
63
64 // Pre-allocate archive relay port so we can include it in announcement
65 let archive_port = TestRelay::find_free_port();
66 let archive_domain = format!("127.0.0.1:{}", archive_port);
67
68 // 2. Create test repository locally with deterministic commit
69 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
70 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
71 .expect("Failed to create test repo");
72
73 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
74
75 // 3. Create and send announcement listing BOTH relays
76 // This ensures the archive relay will accept the state event when it syncs
77 let announcement = create_repo_announcement(
78 &keys,
79 &[&source_relay.domain(), &archive_domain],
80 identifier,
81 );
82
83 let source_client = Client::new(keys.clone());
84 source_client
85 .add_relay(source_relay.url())
86 .await
87 .expect("Failed to add source relay");
88 source_client.connect().await;
89
90 // Wait for connection
91 tokio::time::sleep(Duration::from_millis(500)).await;
92
93 // Send announcement to source relay
94 source_client
95 .send_event(&announcement)
96 .await
97 .expect("Failed to send announcement to source");
98
99 tokio::time::sleep(Duration::from_millis(200)).await;
100
101 // 4. Create and send state event
102 let clone_urls = [
103 format!(
104 "http://{}/{}/{}.git",
105 source_relay.domain(),
106 npub,
107 identifier
108 ),
109 format!("http://{}/{}/{}.git", archive_domain, npub, identifier),
110 ];
111 let relay_urls = [
112 source_relay.url().to_string(),
113 format!("ws://{}", archive_domain),
114 ];
115
116 let state_event = create_state_event(
117 &keys,
118 identifier,
119 &[("main", &commit_hash)],
120 &[],
121 &[&clone_urls[0], &clone_urls[1]],
122 &[&relay_urls[0], &relay_urls[1]],
123 )
124 .expect("Failed to create state event");
125
126 let state_event_id = state_event.id;
127
128 // Send state event to source relay (goes to purgatory - no git data yet)
129 source_client
130 .send_event(&state_event)
131 .await
132 .expect("Failed to send state event to source");
133
134 tokio::time::sleep(Duration::from_millis(200)).await;
135
136 // 5. Push git data to source relay
137 // The state event in purgatory authorizes this push
138 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
139 .expect("Push to source should succeed");
140
141 // After push, state event should be released from purgatory on source relay
142 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
143 .await
144 .expect("State event should be served on source relay after push");
145
146 // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
147 let archive_relay = TestRelay::start_with_archive_and_sync(
148 archive_port,
149 Some(source_relay.url().to_string()),
150 false, // negentropy enabled
151 true, // archive_all
152 true, // archive_read_only
153 )
154 .await;
155
156 // Wait for sync connection to establish
157 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
158 .await
159 .expect("Sync connection should establish");
160
161 // 7. Wait for state event to be released on archive relay
162 // The sync should:
163 // a) Fetch the announcement and state event from source relay
164 // b) Accept announcement (creates bare repo structure) - via archive mode
165 // c) Put state event in purgatory (git data missing on archive relay)
166 // d) Fetch git data from source relay's clone URL
167 // e) Release the state event from purgatory
168 let found = wait_for_event_served(
169 archive_relay.url(),
170 &state_event_id,
171 Duration::from_secs(30), // Allow time for sync + git fetch
172 )
173 .await;
174
175 assert!(
176 found.is_ok(),
177 "State event should be served after sync fetches git data: {:?}",
178 found.err()
179 );
180
181 // 8. Verify bare repository was created
182 let repo_path = archive_relay
183 .git_data_path()
184 .join(format!("{}/{}.git", npub, identifier));
185
186 assert!(
187 repo_path.exists(),
188 "Bare repository should be created at {:?} for archive announcement",
189 repo_path
190 );
191
192 // 9. Verify it's a bare repository (check for config file with bare = true)
193 let config_path = repo_path.join("config");
194 assert!(
195 config_path.exists(),
196 "Git config should exist at {:?}",
197 config_path
198 );
199
200 let config_content = tokio::fs::read_to_string(&config_path)
201 .await
202 .expect("Should read git config");
203 assert!(
204 config_content.contains("bare = true"),
205 "Repository at {:?} should be bare (config should contain 'bare = true')",
206 repo_path
207 );
208
209 // 10. Verify refs are correct on archive relay
210 let ref_correct = check_ref_at_commit(
211 &archive_domain,
212 &npub,
213 identifier,
214 "refs/heads/main",
215 &commit_hash,
216 )
217 .await
218 .expect("Failed to check ref");
219
220 assert!(ref_correct, "main branch should point to correct commit");
221
222 // 11. Verify git pushes are rejected (read-only mode)
223 // Create a new commit in the source repo
224 tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content")
225 .await
226 .expect("Failed to write new file");
227
228 let output = tokio::process::Command::new("git")
229 .args(["add", "."])
230 .current_dir(temp_dir.path())
231 .output()
232 .await
233 .expect("Failed to git add");
234 assert!(output.status.success());
235
236 let output = tokio::process::Command::new("git")
237 .args(["commit", "-m", "New commit for push test"])
238 .current_dir(temp_dir.path())
239 .output()
240 .await
241 .expect("Failed to git commit");
242 assert!(output.status.success());
243
244 // Try to push to archive relay (should fail in read-only mode)
245 let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier);
246 let output = tokio::process::Command::new("git")
247 .args(["push", &push_url, "main"])
248 .current_dir(temp_dir.path())
249 .output()
250 .await
251 .expect("Failed to run git push");
252
253 assert!(
254 !output.status.success(),
255 "Git push should be rejected in archive_read_only mode. stderr: {}",
256 String::from_utf8_lossy(&output.stderr)
257 );
258
259 // Cleanup
260 source_client.disconnect().await;
261 archive_relay.stop().await;
262 source_relay.stop().await;
263}
264
265/// Test that archive mode without state events does NOT sync git data.
266///
267/// This verifies the security model: archive mode only syncs git data
268/// when there are state events to validate against.
269///
270/// Scenario:
271/// 1. Start source relay with announcement only (no state events)
272/// 2. Start archive relay syncing from source
273/// 3. Archive relay syncs announcement (creates bare repo)
274/// 4. Verify git data is NOT synced (no state events to trigger purgatory sync)
275#[tokio::test]
276async fn test_archive_without_state_events_does_not_sync_git() {
277 // 1. Start source relay
278 let source_relay = TestRelay::start().await;
279 let keys = Keys::generate();
280 let identifier = "archive-no-state-repo";
281
282 // Pre-allocate archive relay port
283 let archive_port = TestRelay::find_free_port();
284 let archive_domain = format!("127.0.0.1:{}", archive_port);
285
286 // 2. Create test repository locally
287 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
288 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
289 .expect("Failed to create test repo");
290
291 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
292
293 // 3. Create and send announcement listing BOTH relays (but NO state event)
294 let announcement = create_repo_announcement(
295 &keys,
296 &[&source_relay.domain(), &archive_domain],
297 identifier,
298 );
299
300 let source_client = Client::new(keys.clone());
301 source_client
302 .add_relay(source_relay.url())
303 .await
304 .expect("Failed to add source relay");
305 source_client.connect().await;
306
307 tokio::time::sleep(Duration::from_millis(500)).await;
308
309 // Send announcement to source relay
310 source_client
311 .send_event(&announcement)
312 .await
313 .expect("Failed to send announcement to source");
314
315 tokio::time::sleep(Duration::from_millis(200)).await;
316
317 // 4. Push git data to source relay (but no state event to authorize it)
318 // This push will fail because there's no state event in purgatory
319 // That's expected - we're testing that archive mode doesn't blindly fetch git data
320
321 // 5. Start archive relay
322 let archive_relay = TestRelay::start_with_archive_and_sync(
323 archive_port,
324 Some(source_relay.url().to_string()),
325 false,
326 true,
327 true,
328 )
329 .await;
330
331 // Wait for sync
332 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
333 .await
334 .expect("Sync connection should establish");
335
336 // Give time for any potential git sync to happen
337 tokio::time::sleep(Duration::from_secs(3)).await;
338
339 // 6. Verify bare repository was created (announcement was accepted)
340 let repo_path = archive_relay
341 .git_data_path()
342 .join(format!("{}/{}.git", npub, identifier));
343
344 assert!(
345 repo_path.exists(),
346 "Bare repository should be created for archive announcement"
347 );
348
349 // 7. Verify git data was NOT synced (no state events to trigger purgatory sync)
350 // Check that the commit does NOT exist in the archive relay's repo
351 let output = tokio::process::Command::new("git")
352 .args(["cat-file", "-t", &commit_hash])
353 .current_dir(&repo_path)
354 .output()
355 .await;
356
357 let commit_exists = output.map(|o| o.status.success()).unwrap_or(false);
358
359 assert!(
360 !commit_exists,
361 "Git data should NOT be synced without state events (security: validates against Nostr state)"
362 );
363
364 // Cleanup
365 source_client.disconnect().await;
366 archive_relay.stop().await;
367 source_relay.stop().await;
368}
diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs
index 1d06f22..cfcea1c 100644
--- a/tests/common/purgatory_helpers.rs
+++ b/tests/common/purgatory_helpers.rs
@@ -338,6 +338,44 @@ pub fn build_repo_coord(keys: &Keys, identifier: &str) -> String {
338 format!("30617:{}:{}", keys.public_key().to_hex(), identifier) 338 format!("30617:{}:{}", keys.public_key().to_hex(), identifier)
339} 339}
340 340
341/// Create a repository announcement event (kind 30617) for purgatory tests.
342///
343/// Creates a minimal but valid NIP-34 repository announcement with a `d` tag,
344/// optional `clone` URLs, and optional `relays` URLs.
345///
346/// # Arguments
347/// * `keys` - Keys for signing
348/// * `identifier` - Repository identifier (d-tag)
349/// * `clone_urls` - Clone URLs to include (may be empty)
350/// * `relay_urls` - Relay URLs to include (may be empty)
351///
352/// # Returns
353/// * `Ok(Event)` - Signed announcement event
354/// * `Err(String)` - If signing fails
355pub fn create_announcement_event(
356 keys: &Keys,
357 identifier: &str,
358 clone_urls: &[&str],
359 relay_urls: &[&str],
360) -> Result<Event, String> {
361 let mut tags = vec![Tag::identifier(identifier)];
362
363 if !clone_urls.is_empty() {
364 let urls: Vec<String> = clone_urls.iter().map(|s| s.to_string()).collect();
365 tags.push(Tag::custom(TagKind::custom("clone"), urls));
366 }
367
368 if !relay_urls.is_empty() {
369 let urls: Vec<String> = relay_urls.iter().map(|s| s.to_string()).collect();
370 tags.push(Tag::custom(TagKind::custom("relays"), urls));
371 }
372
373 EventBuilder::new(Kind::GitRepoAnnouncement, "")
374 .tags(tags)
375 .sign_with_keys(keys)
376 .map_err(|e| format!("Failed to sign announcement event: {}", e))
377}
378
341/// Wait for an event to be served by a relay (not in purgatory). 379/// Wait for an event to be served by a relay (not in purgatory).
342/// 380///
343/// Polls the relay until the event is queryable, indicating it has 381/// Polls the relay until the event is queryable, indicating it has
diff --git a/tests/common/relay.rs b/tests/common/relay.rs
index 227849a..b1e96cf 100644
--- a/tests/common/relay.rs
+++ b/tests/common/relay.rs
@@ -204,7 +204,7 @@ impl TestRelay {
204 .env("NGIT_GIT_DATA_PATH", git_data_dir.path()) 204 .env("NGIT_GIT_DATA_PATH", git_data_dir.path())
205 .env("NGIT_DATABASE_BACKEND", "memory") // Force in-memory database for isolation 205 .env("NGIT_DATABASE_BACKEND", "memory") // Force in-memory database for isolation
206 .env("NGIT_OWNER_NPUB", &test_npub) 206 .env("NGIT_OWNER_NPUB", &test_npub)
207 .env("NGIT_SYNC_BATCH_WINDOW_MS", "200") // Fast batch window for tests (200ms instead of 5s default) 207 .env("NGIT_TEST", "1") // Enable test mode: fast timers (200ms batch window, 200ms purgatory sync)
208 .env("NGIT_SYNC_STARTUP_DELAY_SECS", "0") // No startup delay for faster tests 208 .env("NGIT_SYNC_STARTUP_DELAY_SECS", "0") // No startup delay for faster tests
209 .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests 209 .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests
210 .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests 210 .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests
@@ -213,8 +213,15 @@ impl TestRelay {
213 "RUST_LOG", 213 "RUST_LOG",
214 std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), 214 std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
215 ) // Use RUST_LOG from environment or default to info 215 ) // Use RUST_LOG from environment or default to info
216 .stdout(Stdio::null()) // Suppress stdout for cleaner test output 216 .stdout(
217 .stderr(Stdio::null()); // Suppress stderr for cleaner test output 217 std::fs::OpenOptions::new()
218 .create(true)
219 .append(true)
220 .open(format!("/tmp/relay-{}.log", port))
221 .map(Stdio::from)
222 .unwrap_or(Stdio::null()),
223 )
224 .stderr(Stdio::inherit()); // Inherit stderr for test output
218 225
219 // Add bootstrap relay URL if provided 226 // Add bootstrap relay URL if provided
220 if let Some(ref bootstrap_url) = bootstrap_relay_url { 227 if let Some(ref bootstrap_url) = bootstrap_relay_url {
diff --git a/tests/common/sync_helpers.rs b/tests/common/sync_helpers.rs
index 5fc2ad7..af51e78 100644
--- a/tests/common/sync_helpers.rs
+++ b/tests/common/sync_helpers.rs
@@ -507,41 +507,53 @@ fn check_sync_connections_in_metrics(metrics: &str, expected: usize) -> bool {
507/// assert!(found, "Expected event {} to sync to relay", event.id); 507/// assert!(found, "Expected event {} to sync to relay", event.id);
508/// ``` 508/// ```
509pub async fn wait_for_event_on_relay(relay_url: &str, filter: Filter, timeout: Duration) -> bool { 509pub async fn wait_for_event_on_relay(relay_url: &str, filter: Filter, timeout: Duration) -> bool {
510 // Create a temporary client for querying 510 let deadline = tokio::time::Instant::now() + timeout;
511 let temp_keys = Keys::generate(); 511 let poll_interval = Duration::from_millis(200);
512 let client = Client::new(temp_keys);
513
514 // Try to connect
515 if client.add_relay(relay_url).await.is_err() {
516 return false;
517 }
518 512
519 client.connect().await; 513 loop {
514 // Create a fresh client for each poll attempt (avoids stale connection state)
515 let temp_keys = Keys::generate();
516 let client = Client::new(temp_keys);
520 517
521 // Wait for connection (brief timeout) 518 if client.add_relay(relay_url).await.is_err() {
522 let mut connected = false; 519 if tokio::time::Instant::now() >= deadline {
523 for _ in 0..10 { 520 return false;
524 tokio::time::sleep(Duration::from_millis(100)).await; 521 }
525 let relays = client.relays().await; 522 tokio::time::sleep(poll_interval).await;
526 if relays.values().any(|r| r.is_connected()) { 523 continue;
527 connected = true;
528 break;
529 } 524 }
530 }
531 525
532 if !connected { 526 client.connect().await;
533 client.disconnect().await; 527
534 return false; 528 // Wait for connection
535 } 529 let mut connected = false;
530 for _ in 0..10 {
531 tokio::time::sleep(Duration::from_millis(100)).await;
532 let relays = client.relays().await;
533 if relays.values().any(|r| r.is_connected()) {
534 connected = true;
535 break;
536 }
537 }
536 538
537 // Fetch events with the provided timeout 539 if connected {
538 let result = client.fetch_events(filter, timeout).await; 540 // Use a short fetch window — if the event is there, EOSE comes back quickly
541 let fetch_timeout = Duration::from_millis(500);
542 let result = client.fetch_events(filter.clone(), fetch_timeout).await;
543 client.disconnect().await;
539 544
540 client.disconnect().await; 545 match result {
546 Ok(events) if !events.is_empty() => return true,
547 _ => {}
548 }
549 } else {
550 client.disconnect().await;
551 }
541 552
542 match result { 553 if tokio::time::Instant::now() >= deadline {
543 Ok(events) => !events.is_empty(), 554 return false;
544 Err(_) => false, 555 }
556 tokio::time::sleep(poll_interval).await;
545 } 557 }
546} 558}
547 559
@@ -774,6 +786,11 @@ impl MetricsTestHarness {
774 self.source_relays[idx].domain() 786 self.source_relays[idx].domain()
775 } 787 }
776 788
789 /// Get a reference to a source relay (for advanced test operations)
790 pub fn source_relay(&self, idx: usize) -> &TestRelay {
791 &self.source_relays[idx]
792 }
793
777 /// Submit events to a specific source relay 794 /// Submit events to a specific source relay
778 pub async fn submit_events(&self, source_idx: usize, events: &[Event]) -> Result<(), String> { 795 pub async fn submit_events(&self, source_idx: usize, events: &[Event]) -> Result<(), String> {
779 let relay = &self.source_relays[source_idx]; 796 let relay = &self.source_relays[source_idx];
@@ -1071,12 +1088,16 @@ pub struct SyncTestResult {
1071 pub syncing_relay: TestRelay, 1088 pub syncing_relay: TestRelay,
1072 pub maintainer_keys: Keys, 1089 pub maintainer_keys: Keys,
1073 pub repo_coord: String, 1090 pub repo_coord: String,
1091 // Keep SmartGitServer alive for the test duration
1092 _git_server: Option<super::git_server::SmartGitServer>,
1093 // Keep temp dir alive for the test duration
1094 _git_temp_dir: Option<tempfile::TempDir>,
1074} 1095}
1075 1096
1076/// Helper to send an event to a relay 1097/// Helper to send an event to a relay
1077/// 1098///
1078/// Creates a temporary client, sends the event, and disconnects. 1099/// Creates a temporary client, sends the event, and disconnects.
1079async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> { 1100pub async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
1080 let temp_keys = Keys::generate(); 1101 let temp_keys = Keys::generate();
1081 let client = TestClient::new(relay.url(), temp_keys).await?; 1102 let client = TestClient::new(relay.url(), temp_keys).await?;
1082 client.send_event(event).await?; 1103 client.send_event(event).await?;
@@ -1084,6 +1105,270 @@ async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
1084 Ok(()) 1105 Ok(())
1085} 1106}
1086 1107
1108/// Helper to send an event to a relay by URL
1109///
1110/// Creates a temporary client, sends the event, and disconnects.
1111pub async fn send_to_relay_url(relay_url: &str, event: &Event) -> Result<(), String> {
1112 let temp_keys = Keys::generate();
1113 let client = TestClient::new(relay_url, temp_keys).await?;
1114 client.send_event(event).await?;
1115 client.disconnect().await;
1116 Ok(())
1117}
1118
1119/// Push git repository data to a relay to release a purgatory-held announcement.
1120///
1121/// Creates a local git repo, sends a state event, and pushes to the relay.
1122/// Use this when you need to build a custom announcement but still need the
1123/// relay to accept it (i.e., release it from purgatory).
1124///
1125/// # Arguments
1126/// * `relay` - The relay to push to
1127/// * `keys` - Keys of the repository owner
1128/// * `identifier` - Repository identifier
1129/// * `domains` - All domains in the announcement (for state event URLs)
1130///
1131/// # Returns
1132/// `tempfile::TempDir` - Keep alive for test duration
1133pub async fn push_git_data_to_relay(
1134 relay: &TestRelay,
1135 keys: &Keys,
1136 identifier: &str,
1137 domains: &[&str],
1138) -> tempfile::TempDir {
1139 use super::purgatory_helpers::{
1140 create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
1141 };
1142
1143 let npub = keys
1144 .public_key()
1145 .to_bech32()
1146 .expect("Failed to convert public key to npub");
1147
1148 // Create local git repo
1149 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1150 let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
1151 .expect("Failed to create test git repo");
1152
1153 let clone_urls: Vec<String> = domains
1154 .iter()
1155 .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
1156 .collect();
1157 let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
1158
1159 // Build and send state event with all domains' clone URLs
1160 let state_event = create_state_event(
1161 keys,
1162 identifier,
1163 &[("main", &commit_hash)],
1164 &[],
1165 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1166 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1167 )
1168 .expect("Failed to create state event");
1169
1170 send_to_relay(relay, &state_event)
1171 .await
1172 .expect("Failed to send state event");
1173
1174 // Git push to relay → releases state event from purgatory, authorizes push
1175 push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier)
1176 .expect("Failed to push git data to relay");
1177
1178 // Brief wait for push processing
1179 tokio::time::sleep(Duration::from_millis(500)).await;
1180
1181 git_temp_dir
1182}
1183
1184/// Like `push_git_data_to_relay` but writes a unique marker file so each call
1185/// produces a distinct commit hash.
1186///
1187/// Use this when multiple callers push to the same relay with the same identifier
1188/// but different keys — identical commit hashes cause git to skip pack transfer,
1189/// which can leave the announcement in purgatory.
1190///
1191/// # Arguments
1192/// * `relay` - The relay to push to
1193/// * `keys` - Keys of the repository owner
1194/// * `identifier` - Repository identifier
1195/// * `domains` - All domains in the announcement (for state event URLs)
1196/// * `unique_seed` - A string written into a `.unique` file to differentiate commits
1197///
1198/// # Returns
1199/// `tempfile::TempDir` - Keep alive for test duration
1200pub async fn push_unique_git_data_to_relay(
1201 relay: &TestRelay,
1202 keys: &Keys,
1203 identifier: &str,
1204 domains: &[&str],
1205 unique_seed: &str,
1206) -> tempfile::TempDir {
1207 use super::purgatory_helpers::{create_state_event, push_to_relay};
1208
1209 let npub = keys
1210 .public_key()
1211 .to_bech32()
1212 .expect("Failed to convert public key to npub");
1213
1214 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1215 let path = git_temp_dir.path();
1216
1217 fn git(path: &std::path::Path, args: &[&str]) {
1218 let status = std::process::Command::new("git")
1219 .args(args)
1220 .current_dir(path)
1221 .env("GIT_AUTHOR_NAME", "Test User")
1222 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1223 .env("GIT_COMMITTER_NAME", "Test User")
1224 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1225 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00+00:00")
1226 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00+00:00")
1227 .output()
1228 .unwrap_or_else(|e| panic!("git {:?} failed to spawn: {}", args, e));
1229 assert!(
1230 status.status.success(),
1231 "git {:?} failed: {}",
1232 args,
1233 String::from_utf8_lossy(&status.stderr)
1234 );
1235 }
1236
1237 git(path, &["init", "--initial-branch=main"]);
1238 git(path, &["config", "user.email", "test@example.com"]);
1239 git(path, &["config", "user.name", "Test User"]);
1240 git(path, &["config", "commit.gpgsign", "false"]);
1241
1242 // Write a unique file so each maintainer gets a distinct commit hash
1243 std::fs::write(path.join("state_test.txt"), "State test content for purgatory sync")
1244 .expect("write state_test.txt");
1245 std::fs::write(path.join(".unique"), unique_seed).expect("write .unique");
1246 git(path, &["add", "."]);
1247 git(path, &["commit", "-m", "State test commit"]);
1248
1249 let commit_hash = {
1250 let out = std::process::Command::new("git")
1251 .args(["rev-parse", "HEAD"])
1252 .current_dir(path)
1253 .output()
1254 .expect("git rev-parse");
1255 String::from_utf8_lossy(&out.stdout).trim().to_string()
1256 };
1257
1258 let clone_urls: Vec<String> = domains
1259 .iter()
1260 .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
1261 .collect();
1262 let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
1263
1264 let state_event = create_state_event(
1265 keys,
1266 identifier,
1267 &[("main", &commit_hash)],
1268 &[],
1269 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1270 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1271 )
1272 .expect("Failed to create state event");
1273
1274 send_to_relay(relay, &state_event)
1275 .await
1276 .expect("Failed to send state event");
1277
1278 push_to_relay(path, &relay.domain(), &npub, identifier)
1279 .expect("Failed to push git data to relay");
1280
1281 tokio::time::sleep(Duration::from_millis(500)).await;
1282
1283 git_temp_dir
1284}
1285
1286/// Set up a repository announcement on a relay with git data so it passes purgatory.
1287///
1288/// With the announcement purgatory feature, announcements (kind 30617) require git
1289/// data before they are promoted to the relay's main DB. This helper:
1290///
1291/// 1. Creates a local git repo with a commit
1292/// 2. Builds an announcement and state event (kind 30618) pointing to the relay
1293/// 3. Sends both to the relay (they go to purgatory)
1294/// 4. Git pushes to the relay → releases both from purgatory immediately
1295/// 5. Returns the announcement event and temp dir (keep alive for test duration)
1296///
1297/// # Arguments
1298/// * `relay` - The relay to set up the announcement on
1299/// * `keys` - Keys to sign the announcement with (repo owner)
1300/// * `domains` - All domains that should be listed in the announcement (including relay.domain())
1301/// * `identifier` - Repository identifier (d-tag)
1302///
1303/// # Returns
1304/// `(Event, tempfile::TempDir)` - The announcement event and temp dir.
1305/// The temp dir MUST be kept alive for the duration of the test.
1306pub async fn setup_announcement_on_relay(
1307 relay: &TestRelay,
1308 keys: &Keys,
1309 domains: &[&str],
1310 identifier: &str,
1311) -> (Event, tempfile::TempDir) {
1312 use super::purgatory_helpers::{
1313 create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
1314 };
1315
1316 let npub = keys
1317 .public_key()
1318 .to_bech32()
1319 .expect("Failed to convert public key to npub");
1320
1321 // Create local git repo with a commit
1322 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1323 let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
1324 .expect("Failed to create test git repo");
1325
1326 // Build clone URLs and relay URLs from domains
1327 let clone_urls: Vec<String> = domains
1328 .iter()
1329 .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
1330 .collect();
1331 let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
1332
1333 // Build announcement event (lists ALL domains for relay discovery)
1334 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
1335 .tags(vec![
1336 Tag::identifier(identifier),
1337 Tag::custom(TagKind::custom("clone"), clone_urls.clone()),
1338 Tag::custom(TagKind::custom("relays"), relay_urls.clone()),
1339 ])
1340 .sign_with_keys(keys)
1341 .expect("Failed to sign repo announcement");
1342
1343 // Build state event with all domains' clone URLs
1344 let state_event = create_state_event(
1345 keys,
1346 identifier,
1347 &[("main", &commit_hash)],
1348 &[],
1349 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1350 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1351 )
1352 .expect("Failed to create state event");
1353
1354 // Send announcement and state event to relay (both go to purgatory)
1355 send_to_relay(relay, &announcement)
1356 .await
1357 .expect("Failed to send announcement");
1358 send_to_relay(relay, &state_event)
1359 .await
1360 .expect("Failed to send state event");
1361
1362 // Git push to relay → releases both from purgatory
1363 push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier)
1364 .expect("Failed to push git data to relay");
1365
1366 // Brief wait for push processing
1367 tokio::time::sleep(Duration::from_millis(500)).await;
1368
1369 (announcement, git_temp_dir)
1370}
1371
1087/// Unified sync test helper that automatically determines sync mode. 1372/// Unified sync test helper that automatically determines sync mode.
1088/// 1373///
1089/// This function sets up a complete sync test environment by determining whether 1374/// This function sets up a complete sync test environment by determining whether
@@ -1119,6 +1404,10 @@ async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
1119/// // Assert comment synced to result.syncing_relay 1404/// // Assert comment synced to result.syncing_relay
1120/// ``` 1405/// ```
1121pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) -> SyncTestResult { 1406pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) -> SyncTestResult {
1407 use super::purgatory_helpers::{
1408 create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
1409 };
1410
1122 // Validate usage - cannot provide events in both slices 1411 // Validate usage - cannot provide events in both slices
1123 let historic_mode = !historic_events.is_empty(); 1412 let historic_mode = !historic_events.is_empty();
1124 let live_mode = !live_events.is_empty(); 1413 let live_mode = !live_events.is_empty();
@@ -1137,39 +1426,93 @@ pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) ->
1137 // 2. Start source relay 1426 // 2. Start source relay
1138 let source = TestRelay::start().await; 1427 let source = TestRelay::start().await;
1139 1428
1140 // 3. Create keys and announcement listing both relays 1429 // 3. Create local git repo with a commit
1430 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1431 let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
1432 .expect("Failed to create test git repo");
1433
1434 // 4. Create keys and build URLs
1141 let keys = Keys::generate(); 1435 let keys = Keys::generate();
1142 let announcement = 1436 let npub = keys
1143 create_repo_announcement(&keys, &[&source.domain(), &syncing_domain], "test-repo"); 1437 .public_key()
1438 .to_bech32()
1439 .expect("Failed to convert public key to npub");
1440
1441 // Clone URLs: source relay HTTP endpoint is where git data lives
1442 // The syncing relay's purgatory will fetch from source's clone URL
1443 let clone_url_source = format!("http://{}/{}/{}.git", source.domain(), npub, "test-repo");
1444 let clone_url_syncing = format!("http://{}/{}/{}.git", syncing_domain, npub, "test-repo");
1144 1445
1145 // 4. Send announcement + historic events to source BEFORE syncing relay starts 1446 let clone_urls = vec![clone_url_source.clone(), clone_url_syncing.clone()];
1447 let relay_urls = vec![
1448 format!("ws://{}", source.domain()),
1449 format!("ws://{}", syncing_domain),
1450 ];
1451
1452 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
1453 .tags(vec![
1454 Tag::identifier("test-repo"),
1455 Tag::custom(TagKind::custom("clone"), clone_urls.clone()),
1456 Tag::custom(TagKind::custom("relays"), relay_urls.clone()),
1457 ])
1458 .sign_with_keys(&keys)
1459 .expect("Failed to sign repo announcement");
1460
1461 // 5. Create state event referencing the commit
1462 let state_event = create_state_event(
1463 &keys,
1464 "test-repo",
1465 &[("main", &commit_hash)],
1466 &[],
1467 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1468 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1469 )
1470 .expect("Failed to create state event");
1471
1472 // 6. Send announcement + state event to source (both go to purgatory)
1146 send_to_relay(&source, &announcement) 1473 send_to_relay(&source, &announcement)
1147 .await 1474 .await
1148 .expect("Failed to send announcement"); 1475 .expect("Failed to send announcement");
1476 send_to_relay(&source, &state_event)
1477 .await
1478 .expect("Failed to send state event");
1479
1480 // 7. Git push to source relay → releases both announcement and state event from purgatory
1481 push_to_relay(git_temp_dir.path(), &source.domain(), &npub, "test-repo")
1482 .expect("Failed to push git data to source relay");
1483
1484 // 8. Wait for source relay to process the push and release events from purgatory
1485 tokio::time::sleep(Duration::from_secs(2)).await;
1486
1487 // 9. Send historic events to source BEFORE syncing relay starts
1149 for event in historic_events { 1488 for event in historic_events {
1150 send_to_relay(&source, event) 1489 send_to_relay(&source, event)
1151 .await 1490 .await
1152 .expect("Failed to send historic event"); 1491 .expect("Failed to send historic event");
1153 } 1492 }
1154 1493
1155 // 5. Start syncing relay (connects to source) 1494 // 10. Start syncing relay (connects to source)
1156 let syncing = 1495 let syncing =
1157 TestRelay::start_on_port_with_options(syncing_port, Some(source.url().into()), false).await; 1496 TestRelay::start_on_port_with_options(syncing_port, Some(source.url().into()), false).await;
1158 1497
1159 // 6. Wait for sync connection to establish 1498 // 11. Wait for sync connection to establish
1160 let _ = wait_for_sync_connection(syncing.url(), 1, Duration::from_secs(5)).await; 1499 let _ = wait_for_sync_connection(syncing.url(), 1, Duration::from_secs(5)).await;
1161 1500
1162 // 7. Send live events AFTER connection established 1501 // 12. Send live events AFTER connection established
1163 for event in live_events { 1502 for event in live_events {
1164 send_to_relay(&source, event) 1503 send_to_relay(&source, event)
1165 .await 1504 .await
1166 .expect("Failed to send live event"); 1505 .expect("Failed to send live event");
1167 } 1506 }
1168 1507
1169 // 8. Allow sync to complete 1508 // 13. Allow sync + purgatory promotion to complete on the syncing relay.
1170 tokio::time::sleep(Duration::from_millis(100)).await; 1509 // The syncing relay receives the announcement (goes to purgatory) and state event.
1510 // The purgatory sync loop (1s interval) fetches git data from source's clone URL
1511 // (http://source-domain/npub/test-repo.git) and releases the announcement.
1512 // We wait up to 8s to allow time for this.
1513 tokio::time::sleep(Duration::from_secs(8)).await;
1171 1514
1172 // 9. Compute repo coordinate before moving keys 1515 // 14. Compute repo coordinate before moving keys
1173 let coordinate = repo_coord(&keys, "test-repo"); 1516 let coordinate = repo_coord(&keys, "test-repo");
1174 1517
1175 SyncTestResult { 1518 SyncTestResult {
@@ -1177,6 +1520,8 @@ pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) ->
1177 syncing_relay: syncing, 1520 syncing_relay: syncing,
1178 maintainer_keys: keys, 1521 maintainer_keys: keys,
1179 repo_coord: coordinate, 1522 repo_coord: coordinate,
1523 _git_server: None,
1524 _git_temp_dir: Some(git_temp_dir),
1180 } 1525 }
1181} 1526}
1182 1527
diff --git a/tests/nip77_negentropy.rs b/tests/nip77_negentropy.rs
index fccfe67..29e62d8 100644
--- a/tests/nip77_negentropy.rs
+++ b/tests/nip77_negentropy.rs
@@ -35,56 +35,67 @@ use common::{sync_helpers::*, TestRelay};
35/// 3. Create a fresh client with empty local database 35/// 3. Create a fresh client with empty local database
36/// 4. Call client.sync() to perform negentropy reconciliation 36/// 4. Call client.sync() to perform negentropy reconciliation
37/// 5. Verify reconciliation found the events on the relay 37/// 5. Verify reconciliation found the events on the relay
38///
39/// Uses kind 10317 (GitUserGraspList) events which are unconditionally accepted
40/// by the relay without requiring a promoted repository. This avoids the
41/// announcements-purgatory system which holds kind 30617 events until git data
42/// arrives, meaning announcement events are not stored in the DB and would not
43/// appear in negentropy sync results.
38#[tokio::test] 44#[tokio::test]
39async fn test_nip77_negentropy_sync_finds_events() { 45async fn test_nip77_negentropy_sync_finds_events() {
40 // 1. Start relay 46 // 1. Start relay
41 let relay = TestRelay::start().await; 47 let relay = TestRelay::start().await;
42 println!("Relay started at {}", relay.url()); 48 println!("Relay started at {}", relay.url());
43 49
44 // 2. Create keys and publish events 50 // 2. Create two distinct keypairs - each publishes a kind 10317 event.
45 let keys = Keys::generate(); 51 // Kind 10317 (GitUserGraspList) is unconditionally accepted and stored in
46 52 // the relay DB, unlike kind 30617 announcements which go to purgatory.
47 // Create a repository announcement that will be accepted by the relay 53 let keys1 = Keys::generate();
48 let announcement = create_repo_announcement(&keys, &[&relay.domain()], "test-repo-nip77"); 54 let keys2 = Keys::generate();
49 let event1_id = announcement.id; 55
56 // Build kind 10317 events (replaceable per pubkey, so two keys = two stored events)
57 let event1 = EventBuilder::new(Kind::GitUserGraspList, "")
58 .tags(vec![Tag::identifier("grasp-list-nip77-a")])
59 .sign_with_keys(&keys1)
60 .expect("Failed to sign event 1");
61 let event1_id = event1.id;
50 println!( 62 println!(
51 "Created event 1: {} (kind {})", 63 "Created event 1: {} (kind {})",
52 event1_id, 64 event1_id,
53 announcement.kind.as_u16() 65 event1.kind.as_u16()
54 ); 66 );
55 67
56 // Create a second event (issue referencing the repo) 68 let event2 = EventBuilder::new(Kind::GitUserGraspList, "")
57 let repo_coord = format!( 69 .tags(vec![Tag::identifier("grasp-list-nip77-b")])
58 "{}:{}:{}", 70 .sign_with_keys(&keys2)
59 Kind::GitRepoAnnouncement.as_u16(), 71 .expect("Failed to sign event 2");
60 keys.public_key().to_hex(), 72 let event2_id = event2.id;
61 "test-repo-nip77"
62 );
63 let issue = build_layer2_issue_event(&keys, &repo_coord, "Test issue for NIP-77")
64 .expect("Failed to build issue event");
65 let event2_id = issue.id;
66 println!( 73 println!(
67 "Created event 2: {} (kind {})", 74 "Created event 2: {} (kind {})",
68 event2_id, 75 event2_id,
69 issue.kind.as_u16() 76 event2.kind.as_u16()
70 ); 77 );
71 78
72 // 3. Send events to relay using TestClient 79 // 3. Send events to relay using TestClient
73 let publish_client = TestClient::new(relay.url(), keys.clone()) 80 let publish_client1 = TestClient::new(relay.url(), keys1.clone())
74 .await 81 .await
75 .expect("Failed to connect to relay"); 82 .expect("Failed to connect to relay");
83 publish_client1
84 .send_event(&event1)
85 .await
86 .expect("Failed to send event 1");
87 publish_client1.disconnect().await;
76 88
77 publish_client 89 let publish_client2 = TestClient::new(relay.url(), keys2.clone())
78 .send_event(&announcement)
79 .await 90 .await
80 .expect("Failed to send announcement"); 91 .expect("Failed to connect to relay");
81 publish_client 92 publish_client2
82 .send_event(&issue) 93 .send_event(&event2)
83 .await 94 .await
84 .expect("Failed to send issue"); 95 .expect("Failed to send event 2");
85 println!("Events published to relay"); 96 publish_client2.disconnect().await;
86 97
87 publish_client.disconnect().await; 98 println!("Events published to relay");
88 99
89 // 4. Wait a moment for events to be stored 100 // 4. Wait a moment for events to be stored
90 tokio::time::sleep(Duration::from_millis(200)).await; 101 tokio::time::sleep(Duration::from_millis(200)).await;
@@ -104,8 +115,8 @@ async fn test_nip77_negentropy_sync_finds_events() {
104 115
105 // 6. Perform negentropy sync with filter matching our events 116 // 6. Perform negentropy sync with filter matching our events
106 let filter = Filter::new() 117 let filter = Filter::new()
107 .author(keys.public_key()) 118 .authors(vec![keys1.public_key(), keys2.public_key()])
108 .kinds(vec![Kind::GitRepoAnnouncement, Kind::GitIssue]); 119 .kind(Kind::GitUserGraspList);
109 120
110 println!("Starting negentropy sync with filter: {:?}", filter); 121 println!("Starting negentropy sync with filter: {:?}", filter);
111 122
diff --git a/tests/purgatory.rs b/tests/purgatory.rs
new file mode 100644
index 0000000..73f85ca
--- /dev/null
+++ b/tests/purgatory.rs
@@ -0,0 +1,89 @@
1//! Purgatory Integration Tests
2//!
3//! Tests ngit-grasp relay's implementation of GRASP-01 purgatory behavior.
4//! Uses grasp-audit library to avoid code duplication.
5//!
6//! # Test Strategy
7//!
8//! - Each test runs in complete isolation with its own fresh relay instance
9//! - Uses macro to eliminate boilerplate while maintaining test isolation
10//! - Calls individual test methods from grasp-audit for minimal duplication
11//! - Automatic cleanup via TestRelay fixture (removes container and temp dirs)
12//!
13//! # Running Tests
14//!
15//! ```bash
16//! # Run all purgatory tests
17//! cargo test --test purgatory
18//!
19//! # Run specific test
20//! cargo test --test purgatory test_state_event_not_served_before_git_data
21//!
22//! # With output
23//! cargo test --test purgatory -- --nocapture
24//! ```
25
26mod common;
27
28use common::TestRelay;
29use grasp_audit::specs::grasp01::PurgatoryTests;
30use grasp_audit::{AuditClient, AuditConfig};
31
32/// Macro to generate isolated integration tests for purgatory
33///
34/// Each test runs with its own fresh relay instance to ensure complete isolation.
35/// This eliminates issues with leftover repositories and ensures clean state.
36macro_rules! isolated_purgatory_test {
37 ($test_name:ident) => {
38 #[tokio::test]
39 async fn $test_name() {
40 let relay = TestRelay::start().await;
41 let config = AuditConfig::isolated();
42 let client = AuditClient::new(relay.url(), config)
43 .await
44 .expect("Failed to create audit client");
45
46 let result = PurgatoryTests::$test_name(&client).await;
47
48 relay.stop().await;
49
50 assert!(
51 result.passed,
52 "{} failed: {}",
53 stringify!($test_name),
54 result.error.as_deref().unwrap_or("unknown error")
55 );
56 }
57 };
58}
59
60// ============================================================
61// Announcement Purgatory Tests
62// ============================================================
63
64isolated_purgatory_test!(test_announcement_not_served_before_git_data);
65isolated_purgatory_test!(test_announcement_served_after_git_push);
66isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement);
67isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement);
68
69// ============================================================
70// Deletion Event Tests (NIP-09)
71// ============================================================
72
73isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_state_event);
74isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_state_event);
75
76// ============================================================
77// State Event Purgatory Tests (already implemented)
78// ============================================================
79
80isolated_purgatory_test!(test_state_event_not_served_before_git_data);
81isolated_purgatory_test!(test_state_event_served_after_git_push);
82
83// ============================================================
84// PR Purgatory Tests
85// ============================================================
86
87isolated_purgatory_test!(test_pr_event_accepted_into_purgatory_and_isnt_served);
88isolated_purgatory_test!(test_pr_event_in_purgatory_git_push_accepted);
89isolated_purgatory_test!(test_pr_event_served_after_git_push);
diff --git a/tests/purgatory_persistence.rs b/tests/purgatory_persistence.rs
index 4dc5e94..655b0d9 100644
--- a/tests/purgatory_persistence.rs
+++ b/tests/purgatory_persistence.rs
@@ -31,9 +31,11 @@
31 31
32mod common; 32mod common;
33 33
34use common::purgatory_helpers::create_announcement_event;
34use ngit_grasp::purgatory::Purgatory; 35use ngit_grasp::purgatory::Purgatory;
35use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason}; 36use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason};
36use nostr_sdk::prelude::*; 37use nostr_sdk::prelude::*;
38use std::collections::HashSet;
37use std::time::Duration; 39use std::time::Duration;
38 40
39/// Helper to create a test event 41/// Helper to create a test event
@@ -120,11 +122,31 @@ async fn test_full_purgatory_save_restore_cycle() {
120 // Add a PR placeholder (git-data-first scenario) 122 // Add a PR placeholder (git-data-first scenario)
121 purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string()); 123 purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string());
122 124
123 // Note: We can't directly test expired events without accessing private fields, 125 // Add an announcement to purgatory (requires a real directory for the repo path)
124 // so we'll focus on testing state and PR events persistence 126 let repo_dir = temp_dir.path().join("repo.git");
127 std::fs::create_dir_all(&repo_dir).unwrap();
128 let ann_keys = Keys::generate();
129 let ann_event = create_announcement_event(
130 &ann_keys,
131 "my-repo",
132 &["http://example.com/my-repo.git"],
133 &["wss://relay.example.com"],
134 )
135 .unwrap();
136 let ann_event_id = ann_event.id;
137 let mut ann_relays = HashSet::new();
138 ann_relays.insert("wss://relay.example.com".to_string());
139 purgatory.add_announcement(
140 ann_event,
141 "my-repo".to_string(),
142 ann_keys.public_key(),
143 repo_dir.clone(),
144 ann_relays,
145 );
125 146
126 // Verify initial counts 147 // Verify initial counts
127 let (state_count, pr_count) = purgatory.count(); 148 let (announcement_count, state_count, pr_count) = purgatory.count();
149 assert_eq!(announcement_count, 1, "Should have 1 announcement");
128 assert_eq!(state_count, 2, "Should have 2 state events"); 150 assert_eq!(state_count, 2, "Should have 2 state events");
129 assert_eq!( 151 assert_eq!(
130 pr_count, 3, 152 pr_count, 3,
@@ -146,13 +168,23 @@ async fn test_full_purgatory_save_restore_cycle() {
146 ); 168 );
147 169
148 // Verify all data was restored 170 // Verify all data was restored
149 let (state_count2, pr_count2) = purgatory2.count(); 171 let (announcement_count2, state_count2, pr_count2) = purgatory2.count();
172 assert_eq!(announcement_count2, 1, "Should have 1 announcement after restore");
150 assert_eq!(state_count2, 2, "Should have 2 state events after restore"); 173 assert_eq!(state_count2, 2, "Should have 2 state events after restore");
151 assert_eq!( 174 assert_eq!(
152 pr_count2, 3, 175 pr_count2, 3,
153 "Should have 3 PR events after restore (2 events + 1 placeholder)" 176 "Should have 3 PR events after restore (2 events + 1 placeholder)"
154 ); 177 );
155 178
179 // Verify announcement was restored correctly
180 let restored_ann = purgatory2
181 .find_announcement(&ann_keys.public_key(), "my-repo")
182 .expect("Announcement should be restored");
183 assert_eq!(restored_ann.event.id, ann_event_id);
184 assert_eq!(restored_ann.identifier, "my-repo");
185 assert_eq!(restored_ann.repo_path, repo_dir);
186 assert!(!restored_ann.soft_expired);
187
156 // Verify specific state events 188 // Verify specific state events
157 let repo1_states = purgatory2.find_state("repo1"); 189 let repo1_states = purgatory2.find_state("repo1");
158 assert_eq!(repo1_states.len(), 1); 190 assert_eq!(repo1_states.len(), 1);
@@ -284,7 +316,7 @@ async fn test_purgatory_downtime_adjustment() {
284 purgatory2.restore_from_disk(&state_path).unwrap(); 316 purgatory2.restore_from_disk(&state_path).unwrap();
285 317
286 // Verify event is still there (downtime was accounted for) 318 // Verify event is still there (downtime was accounted for)
287 let (state_count, _) = purgatory2.count(); 319 let (_, state_count, _) = purgatory2.count();
288 assert_eq!(state_count, 1); 320 assert_eq!(state_count, 1);
289 321
290 let repo1_states = purgatory2.find_state("repo1"); 322 let repo1_states = purgatory2.find_state("repo1");
@@ -410,7 +442,7 @@ async fn test_purgatory_restore_missing_file() {
410 assert!(result.is_err(), "Should error on missing file"); 442 assert!(result.is_err(), "Should error on missing file");
411 443
412 // Purgatory should still be usable (empty state) 444 // Purgatory should still be usable (empty state)
413 let (state_count, pr_count) = purgatory.count(); 445 let (_, state_count, pr_count) = purgatory.count();
414 assert_eq!(state_count, 0); 446 assert_eq!(state_count, 0);
415 assert_eq!(pr_count, 0); 447 assert_eq!(pr_count, 0);
416 448
@@ -419,7 +451,7 @@ async fn test_purgatory_restore_missing_file() {
419 let event = create_test_event(&keys, "test").await; 451 let event = create_test_event(&keys, "test").await;
420 purgatory.add_state(event, "repo1".to_string(), keys.public_key(), false); 452 purgatory.add_state(event, "repo1".to_string(), keys.public_key(), false);
421 453
422 let (state_count, _) = purgatory.count(); 454 let (_, state_count, _) = purgatory.count();
423 assert_eq!(state_count, 1); 455 assert_eq!(state_count, 1);
424} 456}
425 457
@@ -470,7 +502,7 @@ async fn test_purgatory_restore_corrupted_file() {
470 assert!(result.is_err(), "Should error on corrupted file"); 502 assert!(result.is_err(), "Should error on corrupted file");
471 503
472 // Purgatory should still be usable 504 // Purgatory should still be usable
473 let (state_count, pr_count) = purgatory.count(); 505 let (_, state_count, pr_count) = purgatory.count();
474 assert_eq!(state_count, 0); 506 assert_eq!(state_count, 0);
475 assert_eq!(pr_count, 0); 507 assert_eq!(pr_count, 0);
476} 508}
@@ -513,7 +545,7 @@ async fn test_empty_purgatory_save_restore() {
513 purgatory2.restore_from_disk(&state_path).unwrap(); 545 purgatory2.restore_from_disk(&state_path).unwrap();
514 546
515 // Verify empty state 547 // Verify empty state
516 let (state_count, pr_count) = purgatory2.count(); 548 let (_, state_count, pr_count) = purgatory2.count();
517 assert_eq!(state_count, 0); 549 assert_eq!(state_count, 0);
518 assert_eq!(pr_count, 0); 550 assert_eq!(pr_count, 0);
519 assert_eq!(purgatory2.expired_count(), 0); 551 assert_eq!(purgatory2.expired_count(), 0);
@@ -620,7 +652,7 @@ async fn test_purgatory_continues_working_after_restore() {
620 ); 652 );
621 653
622 // Verify both old and new events work 654 // Verify both old and new events work
623 let (state_count, _) = purgatory2.count(); 655 let (_, state_count, _) = purgatory2.count();
624 assert_eq!(state_count, 2); 656 assert_eq!(state_count, 2);
625 657
626 let repo1_states = purgatory2.find_state("repo1"); 658 let repo1_states = purgatory2.find_state("repo1");
@@ -632,7 +664,7 @@ async fn test_purgatory_continues_working_after_restore() {
632 assert_eq!(repo2_states[0].event.id, event2.id); 664 assert_eq!(repo2_states[0].event.id, event2.id);
633 665
634 // Verify cleanup still works 666 // Verify cleanup still works
635 let (state_removed, pr_removed) = purgatory2.cleanup(); 667 let (_, state_removed, pr_removed) = purgatory2.cleanup();
636 // Nothing should be expired yet 668 // Nothing should be expired yet
637 assert_eq!(state_removed, 0); 669 assert_eq!(state_removed, 0);
638 assert_eq!(pr_removed, 0); 670 assert_eq!(pr_removed, 0);
@@ -713,15 +745,15 @@ async fn test_purgatory_entries_expired_during_downtime() {
713 purgatory2.restore_from_disk(&state_path).unwrap(); 745 purgatory2.restore_from_disk(&state_path).unwrap();
714 746
715 // Event should be restored 747 // Event should be restored
716 let (state_count, _) = purgatory2.count(); 748 let (_, state_count, _) = purgatory2.count();
717 assert_eq!(state_count, 1); 749 assert_eq!(state_count, 1);
718 750
719 // Cleanup should work (even if nothing is expired yet) 751 // Cleanup should work (even if nothing is expired yet)
720 let (state_removed, _) = purgatory2.cleanup(); 752 let (_, state_removed, _) = purgatory2.cleanup();
721 // Nothing expired yet since we didn't wait 30 minutes 753 // Nothing expired yet since we didn't wait 30 minutes
722 assert_eq!(state_removed, 0); 754 assert_eq!(state_removed, 0);
723 755
724 let (state_count, _) = purgatory2.count(); 756 let (_, state_count, _) = purgatory2.count();
725 assert_eq!(state_count, 1); 757 assert_eq!(state_count, 1);
726} 758}
727 759
@@ -775,3 +807,100 @@ async fn test_rejected_cache_entries_expired_during_downtime() {
775 assert_eq!(index2.hot_cache_len(), 0); 807 assert_eq!(index2.hot_cache_len(), 0);
776 assert_eq!(index2.cold_index_len(), 1); 808 assert_eq!(index2.cold_index_len(), 1);
777} 809}
810
811/// Test 18: Announcement events are saved and restored across restarts
812#[tokio::test]
813async fn test_announcement_save_restore_cycle() {
814 let temp_dir = tempfile::tempdir().unwrap();
815 let git_data_path = temp_dir.path().join("git");
816 let state_path = temp_dir.path().join("purgatory.json");
817
818 // Create a real bare repo directory (restore skips entries whose path is missing)
819 let repo_dir = temp_dir.path().join("owner.git");
820 std::fs::create_dir_all(&repo_dir).unwrap();
821
822 let purgatory = Purgatory::new(&git_data_path);
823 let keys = Keys::generate();
824
825 let ann_event = create_announcement_event(
826 &keys,
827 "my-repo",
828 &["http://example.com/my-repo.git"],
829 &["wss://relay.example.com"],
830 )
831 .unwrap();
832 let ann_event_id = ann_event.id;
833
834 let mut relays = HashSet::new();
835 relays.insert("wss://relay.example.com".to_string());
836
837 purgatory.add_announcement(
838 ann_event,
839 "my-repo".to_string(),
840 keys.public_key(),
841 repo_dir.clone(),
842 relays.clone(),
843 );
844
845 let (ann_count, _, _) = purgatory.count();
846 assert_eq!(ann_count, 1);
847
848 // Save to disk
849 purgatory.save_to_disk(&state_path).unwrap();
850 assert!(state_path.exists());
851
852 // Restore into a fresh purgatory
853 let purgatory2 = Purgatory::new(&git_data_path);
854 purgatory2.restore_from_disk(&state_path).unwrap();
855
856 assert!(!state_path.exists(), "State file should be deleted after restore");
857
858 let (ann_count2, _, _) = purgatory2.count();
859 assert_eq!(ann_count2, 1, "Announcement should be restored");
860
861 let restored = purgatory2
862 .find_announcement(&keys.public_key(), "my-repo")
863 .expect("Announcement should be findable after restore");
864
865 assert_eq!(restored.event.id, ann_event_id);
866 assert_eq!(restored.identifier, "my-repo");
867 assert_eq!(restored.owner, keys.public_key());
868 assert_eq!(restored.repo_path, repo_dir);
869 assert_eq!(restored.relays, relays);
870 assert!(!restored.soft_expired);
871}
872
873/// Test 19: Announcement with missing repo path is skipped on restore
874#[tokio::test]
875async fn test_announcement_missing_repo_skipped_on_restore() {
876 let temp_dir = tempfile::tempdir().unwrap();
877 let git_data_path = temp_dir.path().join("git");
878 let state_path = temp_dir.path().join("purgatory.json");
879
880 // Point to a path that does NOT exist on disk
881 let missing_repo = temp_dir.path().join("nonexistent.git");
882
883 let purgatory = Purgatory::new(&git_data_path);
884 let keys = Keys::generate();
885
886 let ann_event = create_announcement_event(&keys, "my-repo", &[], &[]).unwrap();
887
888 purgatory.add_announcement(
889 ann_event,
890 "my-repo".to_string(),
891 keys.public_key(),
892 missing_repo,
893 HashSet::new(),
894 );
895
896 purgatory.save_to_disk(&state_path).unwrap();
897
898 let purgatory2 = Purgatory::new(&git_data_path);
899 purgatory2.restore_from_disk(&state_path).unwrap();
900
901 let (ann_count, _, _) = purgatory2.count();
902 assert_eq!(
903 ann_count, 0,
904 "Announcement with missing repo path must be skipped"
905 );
906}
diff --git a/tests/purgatory_sync.rs b/tests/purgatory_sync.rs
index 72f3d81..eefd6bc 100644
--- a/tests/purgatory_sync.rs
+++ b/tests/purgatory_sync.rs
@@ -282,15 +282,20 @@ async fn test_state_event_syncs_from_remote() {
282/// Test that a PR event entering purgatory triggers remote commit fetch 282/// Test that a PR event entering purgatory triggers remote commit fetch
283/// and is released once the commit is available. 283/// and is released once the commit is available.
284/// 284///
285/// Scenario: 285/// Flow on source relay:
286/// 1. Start source relay with repository announcement 286/// 1. Send announcement → purgatory (StateOnly - no git data yet)
287/// 2. Create PR event (goes to purgatory - no git data yet) 287/// 2. Send state event → purgatory (refs point to non-existent commits)
288/// 3. Push commit to refs/nostr/<event-id> (authorized by PR event in purgatory) 288/// 3. Push git data → promotes announcement to Full + releases state event
289/// 4. PR event gets released from purgatory on source relay 289/// 4. Send PR event → purgatory (announcement now Full, so PR events accepted)
290/// 5. Start syncing relay 290/// 5. Push PR commit → releases PR event
291/// 6. Syncing relay syncs PR event (goes to purgatory - no local git data) 291///
292/// 7. Syncing relay fetches commit from source's clone URL 292/// Flow on syncing relay:
293/// 8. Verify PR event is released and refs/nostr/<event-id> created on syncing relay 293/// 6. Start syncing relay
294/// 7. Syncs announcement → purgatory (StateOnly)
295/// 8. Syncs state event → purgatory
296/// 9. Fetches git data → promotes announcement (Full) + releases state event
297/// 10. Syncs PR event → purgatory (announcement now Full)
298/// 11. Fetches PR commit → releases PR event
294#[tokio::test] 299#[tokio::test]
295async fn test_pr_event_syncs_from_remote() { 300async fn test_pr_event_syncs_from_remote() {
296 // 1. Start source relay 301 // 1. Start source relay
@@ -313,8 +318,7 @@ async fn test_pr_event_syncs_from_remote() {
313 .to_bech32() 318 .to_bech32()
314 .expect("Failed to get npub"); 319 .expect("Failed to get npub");
315 320
316 // 3. Create and send announcement listing BOTH relays 321 // 3. Create announcement listing BOTH relays
317 // This ensures the syncing relay will accept the PR event when it syncs
318 let announcement = create_repo_announcement( 322 let announcement = create_repo_announcement(
319 &owner_keys, 323 &owner_keys,
320 &[&source_relay.domain(), &syncing_domain], 324 &[&source_relay.domain(), &syncing_domain],
@@ -331,7 +335,7 @@ async fn test_pr_event_syncs_from_remote() {
331 // Wait for connection 335 // Wait for connection
332 tokio::time::sleep(Duration::from_millis(500)).await; 336 tokio::time::sleep(Duration::from_millis(500)).await;
333 337
334 // Send announcement to source relay (creates bare repo) 338 // Step 1: Send announcement to source relay → purgatory (StateOnly)
335 source_client 339 source_client
336 .send_event(&announcement) 340 .send_event(&announcement)
337 .await 341 .await
@@ -339,8 +343,52 @@ async fn test_pr_event_syncs_from_remote() {
339 343
340 tokio::time::sleep(Duration::from_millis(200)).await; 344 tokio::time::sleep(Duration::from_millis(200)).await;
341 345
342 // 4. Create and send PR event BEFORE pushing 346 // Step 2: Create and send state event → purgatory (no git data yet)
343 // The PR event goes to purgatory on source relay, which authorizes the push 347 let clone_urls = [
348 format!(
349 "http://{}/{}/{}.git",
350 source_relay.domain(),
351 npub,
352 identifier
353 ),
354 format!("http://{}/{}/{}.git", syncing_domain, npub, identifier),
355 ];
356 let relay_urls = [
357 source_relay.url().to_string(),
358 format!("ws://{}", syncing_domain),
359 ];
360
361 let state_event = create_state_event(
362 &owner_keys,
363 identifier,
364 &[("main", &commit_hash)],
365 &[],
366 &[&clone_urls[0], &clone_urls[1]],
367 &[&relay_urls[0], &relay_urls[1]],
368 )
369 .expect("Failed to create state event");
370
371 let state_event_id = state_event.id;
372
373 source_client
374 .send_event(&state_event)
375 .await
376 .expect("Failed to send state event to source");
377
378 tokio::time::sleep(Duration::from_millis(200)).await;
379
380 // Step 3: Push git data to source relay
381 // This promotes the announcement from StateOnly to Full AND releases state event
382 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
383 .expect("Push to source should succeed");
384
385 // Wait for state event to be released from purgatory on source relay
386 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
387 .await
388 .expect("State event should be served on source relay after push");
389
390 // Step 4: Create and send PR event → purgatory
391 // NOW the announcement is promoted (Full), so PR events are accepted
344 let repo_coord = build_repo_coord(&owner_keys, identifier); 392 let repo_coord = build_repo_coord(&owner_keys, identifier);
345 393
346 let pr_event = create_pr_event( 394 let pr_event = create_pr_event(
@@ -367,11 +415,10 @@ async fn test_pr_event_syncs_from_remote() {
367 .await 415 .await
368 .expect("Failed to send PR event to source"); 416 .expect("Failed to send PR event to source");
369 417
370 // Small delay to ensure PR event is processed into purgatory
371 tokio::time::sleep(Duration::from_millis(200)).await; 418 tokio::time::sleep(Duration::from_millis(200)).await;
372 419
373 // 5. Push commit to refs/nostr/<event-id> on source relay 420 // Step 5: Push PR commit to refs/nostr/<event-id> on source relay
374 // The PR event in purgatory authorizes this push 421 // This releases the PR event from purgatory
375 let ref_name = format!("refs/nostr/{}", pr_event_id.to_hex()); 422 let ref_name = format!("refs/nostr/{}", pr_event_id.to_hex());
376 push_ref_to_relay( 423 push_ref_to_relay(
377 temp_dir.path(), 424 temp_dir.path(),
@@ -383,12 +430,12 @@ async fn test_pr_event_syncs_from_remote() {
383 ) 430 )
384 .expect("Push to refs/nostr/<event-id> should succeed"); 431 .expect("Push to refs/nostr/<event-id> should succeed");
385 432
386 // After push, PR event should be released from purgatory on source relay 433 // Wait for PR event to be released from purgatory on source relay
387 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5)) 434 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5))
388 .await 435 .await
389 .expect("PR event should be served on source relay after push"); 436 .expect("PR event should be served on source relay after push");
390 437
391 // 6. Start syncing relay (syncs from source) 438 // Step 6: Start syncing relay (syncs from source)
392 let syncing_relay = TestRelay::start_on_port_with_options( 439 let syncing_relay = TestRelay::start_on_port_with_options(
393 syncing_port, 440 syncing_port,
394 Some(source_relay.url().to_string()), 441 Some(source_relay.url().to_string()),
@@ -401,14 +448,13 @@ async fn test_pr_event_syncs_from_remote() {
401 .await 448 .await
402 .expect("Sync connection should establish"); 449 .expect("Sync connection should establish");
403 450
404 // 7. Wait for PR event to be released on syncing relay 451 // Steps 7-11: Syncing relay syncs events
405 // The sync should: 452 // The sync should:
406 // a) Fetch the announcement and PR event from source relay 453 // a) Sync announcement → purgatory (StateOnly)
407 // b) Accept announcement (creates bare repo structure) 454 // b) Sync state event → purgatory
408 // c) Put PR event in purgatory (commit missing on syncing relay) 455 // c) Fetch git data → promotes announcement (Full) + releases state event
409 // d) Fetch commit from source relay's clone URL 456 // d) Sync PR event → purgatory (announcement now Full)
410 // e) Release the PR event from purgatory 457 // e) Fetch PR commit → releases PR event
411 // f) Create refs/nostr/<event-id> pointing to the commit
412 let found = wait_for_event_served( 458 let found = wait_for_event_served(
413 syncing_relay.url(), 459 syncing_relay.url(),
414 &pr_event_id, 460 &pr_event_id,
@@ -422,7 +468,7 @@ async fn test_pr_event_syncs_from_remote() {
422 found.err() 468 found.err()
423 ); 469 );
424 470
425 // 8. Verify refs/nostr/<event-id> was created on syncing relay 471 // Verify refs/nostr/<event-id> was created on syncing relay
426 let ref_correct = 472 let ref_correct =
427 check_ref_at_commit(&syncing_domain, &npub, identifier, &ref_name, &commit_hash) 473 check_ref_at_commit(&syncing_domain, &npub, identifier, &ref_name, &commit_hash)
428 .await 474 .await
@@ -443,14 +489,20 @@ async fn test_pr_event_syncs_from_remote() {
443/// Test that concurrent state and PR events for the same repository 489/// Test that concurrent state and PR events for the same repository
444/// both sync correctly. 490/// both sync correctly.
445/// 491///
446/// Scenario: 492/// Flow on source relay:
447/// 1. Start source relay with repo containing two commits (main branch + PR commit) 493/// 1. Send announcement → purgatory (StateOnly - no git data yet)
448/// 2. Create and push both commits to source relay 494/// 2. Send state event → purgatory (refs point to non-existent commits)
449/// 3. Send both state event and PR event to source relay 495/// 3. Push git data → promotes announcement to Full + releases state event
450/// 4. Start syncing relay 496/// 4. THEN send PR event → purgatory (announcement now Full, so PR events accepted)
451/// 5. Wait for sync to fetch git data and release both events 497/// 5. Push PR commit → releases PR event
452/// 6. Verify both state event and PR event are served 498///
453/// 7. Verify refs are correct for both (main branch and refs/nostr/<event-id>) 499/// Flow on syncing relay:
500/// 6. Start syncing relay
501/// 7. Syncs announcement → purgatory (StateOnly)
502/// 8. Syncs state event → purgatory
503/// 9. Fetches git data → promotes announcement (Full) + releases state event
504/// 10. Syncs PR event → purgatory (announcement now Full)
505/// 11. Fetches PR commit → releases PR event
454#[tokio::test] 506#[tokio::test]
455async fn test_concurrent_state_and_pr_sync() { 507async fn test_concurrent_state_and_pr_sync() {
456 // 1. Start source relay 508 // 1. Start source relay
@@ -464,15 +516,13 @@ async fn test_concurrent_state_and_pr_sync() {
464 let syncing_domain = format!("127.0.0.1:{}", syncing_port); 516 let syncing_domain = format!("127.0.0.1:{}", syncing_port);
465 517
466 // 2. Create test repository with two commits 518 // 2. Create test repository with two commits
467 // First commit establishes the repo, second commit is used for both state and PR events 519 // First commit establishes the repo (for state event), second commit is for PR
468 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); 520 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
469 let _first_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) 521 let _state_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
470 .expect("Failed to create test repo"); 522 .expect("Failed to create test repo");
471 523
472 // Add second commit - this becomes HEAD of main and is referenced by both events 524 // Add second commit - this is used for the PR event
473 // In a real scenario, the state event would reference the current branch state, 525 let pr_commit =
474 // and the PR would propose changes (which happen to be the same commit here for simplicity)
475 let head_commit =
476 add_commit_to_repo(temp_dir.path(), CommitVariant::PrTest).expect("Failed to add commit"); 526 add_commit_to_repo(temp_dir.path(), CommitVariant::PrTest).expect("Failed to add commit");
477 527
478 let npub = owner_keys 528 let npub = owner_keys
@@ -480,7 +530,7 @@ async fn test_concurrent_state_and_pr_sync() {
480 .to_bech32() 530 .to_bech32()
481 .expect("Failed to get npub"); 531 .expect("Failed to get npub");
482 532
483 // 3. Create and send announcement listing BOTH relays 533 // 3. Create announcement listing BOTH relays
484 let announcement = create_repo_announcement( 534 let announcement = create_repo_announcement(
485 &owner_keys, 535 &owner_keys,
486 &[&source_relay.domain(), &syncing_domain], 536 &[&source_relay.domain(), &syncing_domain],
@@ -497,7 +547,7 @@ async fn test_concurrent_state_and_pr_sync() {
497 // Wait for connection 547 // Wait for connection
498 tokio::time::sleep(Duration::from_millis(500)).await; 548 tokio::time::sleep(Duration::from_millis(500)).await;
499 549
500 // Send announcement to source relay (creates bare repo) 550 // Step 1: Send announcement to source relay → purgatory (StateOnly)
501 source_client 551 source_client
502 .send_event(&announcement) 552 .send_event(&announcement)
503 .await 553 .await
@@ -505,8 +555,7 @@ async fn test_concurrent_state_and_pr_sync() {
505 555
506 tokio::time::sleep(Duration::from_millis(200)).await; 556 tokio::time::sleep(Duration::from_millis(200)).await;
507 557
508 // 4. Create state event referencing the HEAD commit (pr_commit) 558 // Step 2: Create and send state event → purgatory (no git data yet)
509 // After add_commit_to_repo, main points to pr_commit (which includes state_commit in history)
510 let clone_urls = [ 559 let clone_urls = [
511 format!( 560 format!(
512 "http://{}/{}/{}.git", 561 "http://{}/{}/{}.git",
@@ -521,11 +570,13 @@ async fn test_concurrent_state_and_pr_sync() {
521 format!("ws://{}", syncing_domain), 570 format!("ws://{}", syncing_domain),
522 ]; 571 ];
523 572
524 // State event references main at head_commit (the current HEAD) 573 // State event references main at pr_commit (HEAD after add_commit_to_repo).
574 // push_to_relay uses `git push --all` which pushes main -> pr_commit (HEAD),
575 // so the state event must reference pr_commit for push validation to succeed.
525 let state_event = create_state_event( 576 let state_event = create_state_event(
526 &owner_keys, 577 &owner_keys,
527 identifier, 578 identifier,
528 &[("main", &head_commit)], 579 &[("main", &pr_commit)],
529 &[], 580 &[],
530 &[&clone_urls[0], &clone_urls[1]], 581 &[&clone_urls[0], &clone_urls[1]],
531 &[&relay_urls[0], &relay_urls[1]], 582 &[&relay_urls[0], &relay_urls[1]],
@@ -534,20 +585,31 @@ async fn test_concurrent_state_and_pr_sync() {
534 585
535 let state_event_id = state_event.id; 586 let state_event_id = state_event.id;
536 587
537 // Send state event to source relay (goes to purgatory - no git data yet)
538 source_client 588 source_client
539 .send_event(&state_event) 589 .send_event(&state_event)
540 .await 590 .await
541 .expect("Failed to send state event to source"); 591 .expect("Failed to send state event to source");
542 592
543 // 5. Create PR event referencing the same commit (head_commit) 593 tokio::time::sleep(Duration::from_millis(200)).await;
544 // This simulates a PR that proposes the changes in head_commit 594
595 // Step 3: Push git data to source relay
596 // This promotes the announcement from StateOnly to Full AND releases state event
597 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
598 .expect("Push to source should succeed");
599
600 // Wait for state event to be released from purgatory on source relay
601 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
602 .await
603 .expect("State event should be served on source relay after push");
604
605 // Step 4: Create and send PR event → purgatory
606 // NOW the announcement is promoted (Full), so PR events are accepted
545 let repo_coord = build_repo_coord(&owner_keys, identifier); 607 let repo_coord = build_repo_coord(&owner_keys, identifier);
546 608
547 let pr_event = create_pr_event( 609 let pr_event = create_pr_event(
548 &pr_author_keys, 610 &pr_author_keys,
549 &repo_coord, 611 &repo_coord,
550 &head_commit, 612 &pr_commit,
551 "Test PR for concurrent sync", 613 "Test PR for concurrent sync",
552 ) 614 )
553 .expect("Failed to create PR event"); 615 .expect("Failed to create PR event");
@@ -570,33 +632,25 @@ async fn test_concurrent_state_and_pr_sync() {
570 632
571 tokio::time::sleep(Duration::from_millis(200)).await; 633 tokio::time::sleep(Duration::from_millis(200)).await;
572 634
573 // 6. Push git data to source relay 635 // Step 5: Push PR commit to refs/nostr/<event-id> on source relay
574 // Push all branches (main contains both commits due to linear history) 636 // This releases the PR event from purgatory
575 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
576 .expect("Push to source should succeed");
577
578 // Also push the PR ref
579 let pr_ref_name = format!("refs/nostr/{}", pr_event_id.to_hex()); 637 let pr_ref_name = format!("refs/nostr/{}", pr_event_id.to_hex());
580 push_ref_to_relay( 638 push_ref_to_relay(
581 temp_dir.path(), 639 temp_dir.path(),
582 &source_relay.domain(), 640 &source_relay.domain(),
583 &npub, 641 &npub,
584 identifier, 642 identifier,
585 &head_commit, 643 &pr_commit,
586 &pr_ref_name, 644 &pr_ref_name,
587 ) 645 )
588 .expect("Push PR ref to source should succeed"); 646 .expect("Push PR ref to source should succeed");
589 647
590 // After push, both events should be released from purgatory on source relay 648 // Wait for PR event to be released from purgatory on source relay
591 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
592 .await
593 .expect("State event should be served on source relay after push");
594
595 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5)) 649 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5))
596 .await 650 .await
597 .expect("PR event should be served on source relay after push"); 651 .expect("PR event should be served on source relay after push");
598 652
599 // 7. Start syncing relay (syncs from source) 653 // Step 6: Start syncing relay (syncs from source)
600 let syncing_relay = TestRelay::start_on_port_with_options( 654 let syncing_relay = TestRelay::start_on_port_with_options(
601 syncing_port, 655 syncing_port,
602 Some(source_relay.url().to_string()), 656 Some(source_relay.url().to_string()),
@@ -609,8 +663,13 @@ async fn test_concurrent_state_and_pr_sync() {
609 .await 663 .await
610 .expect("Sync connection should establish"); 664 .expect("Sync connection should establish");
611 665
612 // 8. Wait for BOTH events to be released on syncing relay 666 // Steps 7-11: Syncing relay syncs events
613 // The sync should fetch git data and release both events 667 // The sync should:
668 // a) Sync announcement → purgatory (StateOnly)
669 // b) Sync state event → purgatory
670 // c) Fetch git data → promotes announcement (Full) + releases state event
671 // d) Sync PR event → purgatory (announcement now Full)
672 // e) Fetch PR commit → releases PR event
614 let state_found = wait_for_event_served( 673 let state_found = wait_for_event_served(
615 syncing_relay.url(), 674 syncing_relay.url(),
616 &state_event_id, 675 &state_event_id,
@@ -629,18 +688,18 @@ async fn test_concurrent_state_and_pr_sync() {
629 688
630 assert!( 689 assert!(
631 pr_found.is_ok(), 690 pr_found.is_ok(),
632 "PR event should be served after sync fetches git data: {:?}", 691 "PR event should be served after sync fetches commit: {:?}",
633 pr_found.err() 692 pr_found.err()
634 ); 693 );
635 694
636 // 9. Verify refs are correct on syncing relay 695 // Verify refs are correct on syncing relay
637 // Check main branch points to head_commit (the HEAD) 696 // Check main branch points to pr_commit (HEAD after both commits)
638 let main_ref_correct = check_ref_at_commit( 697 let main_ref_correct = check_ref_at_commit(
639 &syncing_domain, 698 &syncing_domain,
640 &npub, 699 &npub,
641 identifier, 700 identifier,
642 "refs/heads/main", 701 "refs/heads/main",
643 &head_commit, 702 &pr_commit, // After push, main points to pr_commit (HEAD)
644 ) 703 )
645 .await 704 .await
646 .expect("Failed to check main ref"); 705 .expect("Failed to check main ref");
@@ -648,24 +707,24 @@ async fn test_concurrent_state_and_pr_sync() {
648 assert!( 707 assert!(
649 main_ref_correct, 708 main_ref_correct,
650 "main branch should point to HEAD commit ({})", 709 "main branch should point to HEAD commit ({})",
651 head_commit 710 pr_commit
652 ); 711 );
653 712
654 // Check refs/nostr/<event-id> points to the same commit 713 // Check refs/nostr/<event-id> points to pr_commit
655 let pr_ref_correct = check_ref_at_commit( 714 let pr_ref_correct = check_ref_at_commit(
656 &syncing_domain, 715 &syncing_domain,
657 &npub, 716 &npub,
658 identifier, 717 identifier,
659 &pr_ref_name, 718 &pr_ref_name,
660 &head_commit, 719 &pr_commit,
661 ) 720 )
662 .await 721 .await
663 .expect("Failed to check PR ref"); 722 .expect("Failed to check PR ref");
664 723
665 assert!( 724 assert!(
666 pr_ref_correct, 725 pr_ref_correct,
667 "refs/nostr/<event-id> should point to commit ({})", 726 "refs/nostr/<event-id> should point to PR commit ({})",
668 head_commit 727 pr_commit
669 ); 728 );
670 729
671 // Cleanup 730 // Cleanup
@@ -921,162 +980,43 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
921 .expect("PR event should be served on mock_relay immediately"); 980 .expect("PR event should be served on mock_relay immediately");
922 981
923 // ======================================================================== 982 // ========================================================================
924 // Step 5: Start syncing_relay WITHOUT bootstrap and publish announcement directly 983 // Step 5: Start syncing_relay with source_grasp as bootstrap
925 // ======================================================================== 984 // ========================================================================
926 985
927 // Start syncing_relay with sync enabled but NO bootstrap relay 986 // Start syncing_relay with source_grasp as bootstrap relay.
928 // This tests relay discovery from announcement's `relays` tag 987 // Negentropy is disabled because MockRelay doesn't support NIP-77, and the
929 // Note: We disable negentropy because MockRelay doesn't support NIP-77, 988 // sync system doesn't properly fall back to REQ+EOSE when negentropy fails.
930 // and the sync system doesn't properly fall back to REQ+EOSE when negentropy fails. 989 //
990 // We do NOT publish the announcement directly to syncing_relay. Instead,
991 // syncing_relay discovers it via the bootstrap connection to source_grasp,
992 // which has the promoted announcement in its database.
931 let syncing_relay = TestRelay::start_on_port_with_options( 993 let syncing_relay = TestRelay::start_on_port_with_options(
932 syncing_port, 994 syncing_port,
933 None, // NO bootstrap - relay discovery via announcement tags 995 Some(source_grasp.url().to_string()), // Bootstrap from source_grasp
934 true, // Disable negentropy - MockRelay doesn't support NIP-77 996 true, // Disable negentropy - MockRelay doesn't support NIP-77
935 ) 997 )
936 .await; 998 .await;
937 999
938 // Publish announcement DIRECTLY to syncing_relay
939 // This triggers relay discovery from the announcement's `relays` tag
940 let syncing_client = Client::new(owner_keys.clone());
941 syncing_client
942 .add_relay(syncing_relay.url())
943 .await
944 .expect("Failed to add syncing_relay");
945 syncing_client.connect().await;
946 tokio::time::sleep(Duration::from_millis(500)).await;
947
948 syncing_client
949 .send_event(&announcement)
950 .await
951 .expect("Failed to send announcement to syncing_relay");
952 tokio::time::sleep(Duration::from_millis(200)).await;
953
954 // Wait for relay discovery and sync connections to establish
955 // syncing_relay should discover source_grasp and mock_relay from announcement's relays tag
956 println!("=== Waiting for sync connections ===");
957 println!("syncing_relay URL: {}", syncing_relay.url());
958 println!("source_grasp URL: {}", source_grasp.url());
959 println!("mock_relay URL: {}", mock_relay.url());
960 println!("git_server URL: {}", git_server.url());
961
962 wait_for_sync_connection(syncing_relay.url(), 2, Duration::from_secs(10))
963 .await
964 .expect(
965 "Sync connections should establish to discovered relays (source_grasp + mock_relay)",
966 );
967 println!("Sync connections established!");
968
969 // Debug: Check metrics to see what relays are connected
970 let metrics_url = syncing_relay
971 .url()
972 .replace("ws://", "http://")
973 .replace("/", "")
974 + "/metrics";
975 println!("Checking metrics at: {}", metrics_url);
976 if let Ok(response) = reqwest::get(&metrics_url).await {
977 if let Ok(metrics) = response.text().await {
978 // Print sync-related metrics
979 for line in metrics.lines() {
980 if line.contains("sync") && !line.starts_with('#') {
981 println!(" {}", line);
982 }
983 }
984 }
985 }
986
987 // Give some time for sync to happen
988 println!("Waiting 10s for events to sync...");
989 tokio::time::sleep(Duration::from_secs(10)).await;
990
991 // Check metrics again after waiting
992 println!("=== Checking metrics after sync wait ===");
993 if let Ok(response) = reqwest::get(&metrics_url).await {
994 if let Ok(metrics) = response.text().await {
995 for line in metrics.lines() {
996 if line.contains("sync") && !line.starts_with('#') {
997 println!(" {}", line);
998 }
999 }
1000 }
1001 }
1002
1003 // Debug: Check if PR event is still on mock_relay
1004 println!("=== Debug: Checking PR event on mock_relay ===");
1005 let pr_on_mock =
1006 wait_for_event_served(mock_relay.url(), &pr_event_id, Duration::from_secs(2)).await;
1007 println!("PR event on mock_relay: {:?}", pr_on_mock.is_ok());
1008 if let Ok(ref pr) = pr_on_mock {
1009 println!("PR event tags:");
1010 for tag in pr.tags.iter() {
1011 println!(" {:?}", tag.as_slice());
1012 }
1013 }
1014
1015 // Debug: Check repo coordinate
1016 let repo_coord = build_repo_coord(&owner_keys, identifier);
1017 println!("Expected repo coordinate: {}", repo_coord);
1018
1019 // Debug: Test if mock_relay responds to tag-based filter (Layer 2 style)
1020 println!("=== Debug: Testing mock_relay tag filter response ===");
1021 let test_client = Client::new(Keys::generate());
1022 test_client
1023 .add_relay(mock_relay.url())
1024 .await
1025 .expect("Failed to add mock_relay");
1026 test_client.connect().await;
1027 tokio::time::sleep(Duration::from_millis(500)).await;
1028
1029 // Build a Layer 2 style filter (by 'a' tag)
1030 let tag_filter =
1031 Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::A), repo_coord.as_str());
1032 println!("Tag filter: {:?}", tag_filter);
1033
1034 let tag_results = test_client
1035 .fetch_events(tag_filter, Duration::from_secs(5))
1036 .await;
1037 match tag_results {
1038 Ok(events) => {
1039 println!("Tag filter returned {} events", events.len());
1040 for event in events.iter() {
1041 println!(" Event ID: {}, Kind: {}", event.id, event.kind.as_u16());
1042 }
1043 }
1044 Err(e) => {
1045 println!("Tag filter query failed: {:?}", e);
1046 }
1047 }
1048 test_client.disconnect().await;
1049
1050 // The syncing relay will: 1000 // The syncing relay will:
1051 // 1. Receive announcement directly (creates bare repo) 1001 // 1. Sync promoted announcement from source_grasp via bootstrap connection → purgatory (no local git data)
1052 // 2. Discover source_grasp and mock_relay from announcement's `relays` tag 1002 // 2. EOSE triggers StateOnly subscription → syncs state event from source_grasp → purgatory sync
1053 // 3. Connect to discovered relays 1003 // 3. Purgatory sync fetches commit_a from source_grasp clone URL → announcement + state promoted
1054 // 4. Sync state event from source_grasp → purgatory (no commit_a locally) 1004 // 4. SelfSubscriber sees promoted announcement → upgrades to Full → connects to mock_relay
1055 // 5. Sync PR event from mock_relay → purgatory (no commit_b locally) 1005 // 5. Syncs PR event from mock_relay → purgatory (no commit_b locally)
1056 // 6. Purgatory sync triggers 1006 // 6. Purgatory sync fetches commit_b from git_server via PR clone tag
1057 // 7. Fetches commit_a from source_grasp clone URL (from announcement clone tag) 1007 // 7. PR event promoted → served
1058 // 8. Fetches commit_b from git_server (from PR event's clone tag)
1059 // 9. Both events released when all OIDs available
1060 1008
1061 // ======================================================================== 1009 // ========================================================================
1062 // Step 6: Verify Results 1010 // Step 6: Verify Results
1063 // ======================================================================== 1011 // ========================================================================
1064 1012
1065 println!("=== Step 6: Verify Results ===");
1066 println!("State event ID: {}", state_event_id);
1067 println!("PR event ID: {}", pr_event_id);
1068 println!("commit_a: {}", commit_a);
1069 println!("commit_b: {}", commit_b);
1070
1071 // Wait for state event to be served on syncing_relay 1013 // Wait for state event to be served on syncing_relay
1072 println!("Waiting for state event on syncing_relay...");
1073 let state_found = wait_for_event_served( 1014 let state_found = wait_for_event_served(
1074 syncing_relay.url(), 1015 syncing_relay.url(),
1075 &state_event_id, 1016 &state_event_id,
1076 Duration::from_secs(30), 1017 Duration::from_secs(30),
1077 ) 1018 )
1078 .await; 1019 .await;
1079 println!("State event result: {:?}", state_found);
1080 assert!( 1020 assert!(
1081 state_found.is_ok(), 1021 state_found.is_ok(),
1082 "State event should be served on syncing_relay: {:?}", 1022 "State event should be served on syncing_relay: {:?}",
@@ -1084,10 +1024,8 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
1084 ); 1024 );
1085 1025
1086 // Wait for PR event to be served on syncing_relay 1026 // Wait for PR event to be served on syncing_relay
1087 println!("Waiting for PR event on syncing_relay...");
1088 let pr_found = 1027 let pr_found =
1089 wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await; 1028 wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
1090 println!("PR event result: {:?}", pr_found);
1091 assert!( 1029 assert!(
1092 pr_found.is_ok(), 1030 pr_found.is_ok(),
1093 "PR event should be served on syncing_relay (fetched commit_b from git_server via PR clone tag): {:?}", 1031 "PR event should be served on syncing_relay (fetched commit_b from git_server via PR clone tag): {:?}",
@@ -1128,7 +1066,6 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
1128 source_client.disconnect().await; 1066 source_client.disconnect().await;
1129 mock_client.disconnect().await; 1067 mock_client.disconnect().await;
1130 pr_client.disconnect().await; 1068 pr_client.disconnect().await;
1131 syncing_client.disconnect().await;
1132 git_server.stop().await; 1069 git_server.stop().await;
1133 mock_relay.stop().await; 1070 mock_relay.stop().await;
1134 syncing_relay.stop().await; 1071 syncing_relay.stop().await;
diff --git a/tests/sync/discovery.rs b/tests/sync/discovery.rs
index 8ed80b5..d45a290 100644
--- a/tests/sync/discovery.rs
+++ b/tests/sync/discovery.rs
@@ -3,10 +3,6 @@
3//! Tests for relay discovery from announcement events. 3//! Tests for relay discovery from announcement events.
4//! When a relay receives an announcement listing another relay, 4//! When a relay receives an announcement listing another relay,
5//! it should discover and connect to that relay to sync events. 5//! it should discover and connect to that relay to sync events.
6//!
7//! # Tests
8//! - Test 2: Direct Layer 3 discovery from Layer 2
9//! - Test 3: Recursive multi-hop Layer 3 discovery
10 6
11use std::time::Duration; 7use std::time::Duration;
12 8
@@ -62,29 +58,26 @@ async fn test_discovers_layer3_via_layer2() {
62 // 3. Create test keys 58 // 3. Create test keys
63 let keys = Keys::generate(); 59 let keys = Keys::generate();
64 60
65 // 4. Create a repository announcement that lists BOTH relays 61 // 4. Set up repository announcement on relay_a with git data
66 let announcement = create_repo_announcement( 62 // (purgatory requires git data before announcements are accepted)
67 &keys, 63 let repo_id = "test-repo-discovery";
68 &[&relay_a.domain(), &relay_b.domain()], 64 let domains = vec![relay_a.domain(), relay_b.domain()];
69 "test-repo-discovery", 65 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
70 );
71 let announcement_id = announcement.id;
72 66
67 let (announcement, _git_dir_a) =
68 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
69 let announcement_id = announcement.id;
73 println!( 70 println!(
74 "Created announcement {} (kind {})", 71 "Announcement {} set up on relay_a with git data",
75 announcement_id, 72 announcement_id
76 announcement.kind.as_u16()
77 ); 73 );
78 for tag in announcement.tags.iter() {
79 println!(" Tag: {:?}", tag.as_slice());
80 }
81 74
82 // 5. Build the repo coordinate for the 'a' tag in the patch 75 // 5. Build the repo coordinate for the 'a' tag in the patch
83 let repo_coord = format!( 76 let repo_coord = format!(
84 "{}:{}:{}", 77 "{}:{}:{}",
85 Kind::GitRepoAnnouncement.as_u16(), 78 Kind::GitRepoAnnouncement.as_u16(),
86 keys.public_key().to_hex(), 79 keys.public_key().to_hex(),
87 "test-repo-discovery" 80 repo_id
88 ); 81 );
89 82
90 // 6. Create a patch event (Layer 2) that references the announcement 83 // 6. Create a patch event (Layer 2) that references the announcement
@@ -97,22 +90,13 @@ async fn test_discovers_layer3_via_layer2() {
97 let patch_id = patch.id; 90 let patch_id = patch.id;
98 91
99 println!("Created patch {} (kind {})", patch_id, patch.kind.as_u16()); 92 println!("Created patch {} (kind {})", patch_id, patch.kind.as_u16());
100 for tag in patch.tags.iter() {
101 println!(" Tag: {:?}", tag.as_slice());
102 }
103 93
104 // 7. Send announcement and patch to relay_a ONLY 94 // 7. Send patch to relay_a
105 let client_a = TestClient::new(relay_a.url(), keys.clone()) 95 let client_a = TestClient::new(relay_a.url(), keys.clone())
106 .await 96 .await
107 .expect("Failed to connect to relay_a"); 97 .expect("Failed to connect to relay_a");
108 98
109 client_a 99 client_a
110 .send_event(&announcement)
111 .await
112 .expect("Failed to send announcement to relay_a");
113 println!("Announcement sent to relay_a");
114
115 client_a
116 .send_event(&patch) 100 .send_event(&patch)
117 .await 101 .await
118 .expect("Failed to send patch to relay_a"); 102 .expect("Failed to send patch to relay_a");
@@ -120,18 +104,10 @@ async fn test_discovers_layer3_via_layer2() {
120 104
121 client_a.disconnect().await; 105 client_a.disconnect().await;
122 106
123 // 8. Send announcement to relay_b directly (triggers discovery of relay_a) 107 // 8. Set up announcement on relay_b (triggers discovery of relay_a)
124 let client_b = TestClient::new(relay_b.url(), keys.clone()) 108 let (_announcement_b, _git_dir_b) =
125 .await 109 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
126 .expect("Failed to connect to relay_b"); 110 println!("Announcement set up on relay_b (should trigger discovery of relay_a)");
127
128 client_b
129 .send_event(&announcement)
130 .await
131 .expect("Failed to send announcement to relay_b");
132 println!("Announcement sent to relay_b (should trigger discovery of relay_a)");
133
134 client_b.disconnect().await;
135 111
136 // 9. Wait for relay_b to discover relay_a and sync the patch 112 // 9. Wait for relay_b to discover relay_a and sync the patch
137 println!("Waiting 3s for relay_b to discover relay_a and sync patch..."); 113 println!("Waiting 3s for relay_b to discover relay_a and sync patch...");
@@ -197,19 +173,20 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() {
197 // 3. Create test keys 173 // 3. Create test keys
198 let keys = Keys::generate(); 174 let keys = Keys::generate();
199 175
200 // 4. Create the event chain on relay_a: 176 // 4. Set up repository on relay_a with git data and a Layer 2 issue
201 177
202 // Layer 1: Repository announcement 178 // Layer 1: Set up announcement with git data
203 let announcement = create_repo_announcement( 179 let domains = vec![relay_a.domain(), relay_b.domain()];
204 &keys, 180 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
205 &[&relay_a.domain(), &relay_b.domain()], 181 let repo_id = "test-repo-chain";
206 "test-repo-chain", 182
207 ); 183 let (announcement, _git_dir_a) =
184 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
208 let announcement_id = announcement.id; 185 let announcement_id = announcement.id;
209 println!("Created announcement {} (Layer 1)", announcement_id); 186 println!("Announcement {} set up on relay_a with git data (Layer 1)", announcement_id);
210 187
211 // Build repo coordinate for Layer 2 reference 188 // Build repo coordinate for Layer 2 reference
212 let repo_coord = repo_coord(&keys, "test-repo-chain"); 189 let repo_coord = repo_coord(&keys, repo_id);
213 190
214 // Layer 2: Issue referencing the repo 191 // Layer 2: Issue referencing the repo
215 let issue = build_layer2_issue_event(&keys, &repo_coord, "Test issue for chain discovery") 192 let issue = build_layer2_issue_event(&keys, &repo_coord, "Test issue for chain discovery")
@@ -217,35 +194,23 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() {
217 let issue_id = issue.id; 194 let issue_id = issue.id;
218 println!("Created issue {} (Layer 2)", issue_id); 195 println!("Created issue {} (Layer 2)", issue_id);
219 196
220 // 5. Send all events to relay_a 197 // 5. Send issue to relay_a
221 let client_a = TestClient::new(relay_a.url(), keys.clone()) 198 let client_a = TestClient::new(relay_a.url(), keys.clone())
222 .await 199 .await
223 .expect("Failed to connect to relay_a"); 200 .expect("Failed to connect to relay_a");
224 201
225 client_a 202 client_a
226 .send_event(&announcement)
227 .await
228 .expect("Failed to send announcement");
229 client_a
230 .send_event(&issue) 203 .send_event(&issue)
231 .await 204 .await
232 .expect("Failed to send issue"); 205 .expect("Failed to send issue");
233 206
234 println!("Events sent to relay_a"); 207 println!("Issue sent to relay_a");
235 client_a.disconnect().await; 208 client_a.disconnect().await;
236 209
237 // 6. Send only the announcement to relay_b (triggers discovery) 210 // 6. Set up announcement on relay_b (triggers discovery of relay_a)
238 let client_b = TestClient::new(relay_b.url(), keys.clone()) 211 let (_announcement_b, _git_dir_b) =
239 .await 212 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
240 .expect("Failed to connect to relay_b"); 213 println!("Announcement set up on relay_b (should trigger discovery of relay_a)");
241
242 client_b
243 .send_event(&announcement)
244 .await
245 .expect("Failed to send announcement to relay_b");
246 println!("Announcement sent to relay_b (should trigger discovery)");
247
248 client_b.disconnect().await;
249 214
250 // 7. Wait for sync 215 // 7. Wait for sync
251 println!("Waiting 3s for Layer 2 sync..."); 216 println!("Waiting 3s for Layer 2 sync...");
@@ -271,163 +236,3 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() {
271 ); 236 );
272} 237}
273 238
274/// Test 3: 3-relay recursive discovery - relay discovers third relay through bootstrap
275///
276/// Scenario:
277/// ```text
278/// relay_a (SUT) relay_b (bootstrap) relay_c (discovered)
279/// │ │ │
280/// │ │ has announcement_x │ has announcement_y
281/// │ │ listing A+B+C │ listing A+C
282/// │ │ │
283/// ├────connect──────────► │
284/// │◄───sync announcement_x───────────────────────
285/// │ │
286/// │ discovers relay_c from announcement_x │
287/// │ │
288/// ├─────────────connect─────────────────────────►
289/// │◄────────────sync announcement_y─────────────┘
290/// ```
291///
292/// This tests that relay_a:
293/// 1. Connects to relay_b (configured as bootstrap)
294/// 2. Receives announcement_x which lists relay_c
295/// 3. Discovers and connects to relay_c
296/// 4. Syncs announcement_y from relay_c
297///
298#[tokio::test]
299async fn test_recursive_relay_discovery_via_announcements_with_historic_sync() {
300 // 1. Start all three relays
301
302 // relay_b - will be the bootstrap relay, has announcement_x
303 let relay_b = TestRelay::start().await;
304 println!(
305 "relay_b (bootstrap) started at {} (domain: {})",
306 relay_b.url(),
307 relay_b.domain()
308 );
309
310 // relay_c - will be discovered via announcement_x, has announcement_y
311 let relay_c = TestRelay::start().await;
312 println!(
313 "relay_c (to be discovered) started at {} (domain: {})",
314 relay_c.url(),
315 relay_c.domain()
316 );
317
318 // relay_a - SUT, starts with relay_b as bootstrap
319 let relay_a = TestRelay::start_with_sync(Some(relay_b.url().to_string())).await;
320 println!(
321 "relay_a (SUT) started at {} (domain: {})",
322 relay_a.url(),
323 relay_a.domain()
324 );
325
326 // 2. Create test keys (one for each announcement)
327 let keys_x = Keys::generate();
328 let keys_y = Keys::generate();
329
330 // 3. Create announcement_x on relay_b (lists all three relays: A+B+C)
331 let announcement_x = create_repo_announcement(
332 &keys_x,
333 &[&relay_a.domain(), &relay_b.domain(), &relay_c.domain()],
334 "repo-x-all-relays",
335 );
336 let announcement_x_id = announcement_x.id;
337 println!("Created announcement_x {} listing A+B+C", announcement_x_id);
338 for tag in announcement_x.tags.iter() {
339 println!(" Tag: {:?}", tag.as_slice());
340 }
341
342 // 4. Create announcement_y on relay_c (lists only A+C, NOT B)
343 let announcement_y = create_repo_announcement(
344 &keys_y,
345 &[&relay_a.domain(), &relay_c.domain()],
346 "repo-y-ac-only",
347 );
348 let announcement_y_id = announcement_y.id;
349 println!(
350 "Created announcement_y {} listing A+C only",
351 announcement_y_id
352 );
353 for tag in announcement_y.tags.iter() {
354 println!(" Tag: {:?}", tag.as_slice());
355 }
356
357 // 5. Send announcement_x to relay_b only
358 let client_b = TestClient::new(relay_b.url(), keys_x.clone())
359 .await
360 .expect("Failed to connect to relay_b");
361
362 client_b
363 .send_event(&announcement_x)
364 .await
365 .expect("Failed to send announcement_x to relay_b");
366 println!("announcement_x sent to relay_b");
367
368 client_b.disconnect().await;
369
370 // 6. Send announcement_y to relay_c only
371 let client_c = TestClient::new(relay_c.url(), keys_y.clone())
372 .await
373 .expect("Failed to connect to relay_c");
374
375 client_c
376 .send_event(&announcement_y)
377 .await
378 .expect("Failed to send announcement_y to relay_c");
379 println!("announcement_y sent to relay_c");
380
381 client_c.disconnect().await;
382
383 // 7. Wait for relay_a to:
384 // - Sync from bootstrap relay_b (gets announcement_x)
385 // - Discover relay_c from announcement_x's relays tag
386 // - Connect to relay_c and sync announcement_y
387 println!("Waiting 5s for recursive relay discovery...");
388 tokio::time::sleep(Duration::from_secs(5)).await;
389
390 // 8. Verify announcement_x was synced to relay_a (from bootstrap relay_b)
391 let filter_x = Filter::new()
392 .kind(Kind::GitRepoAnnouncement)
393 .author(keys_x.public_key());
394
395 let announcement_x_synced =
396 wait_for_event_on_relay(relay_a.url(), filter_x, Duration::from_secs(5)).await;
397
398 println!(
399 "announcement_x {} synced to relay_a: {}",
400 announcement_x_id, announcement_x_synced
401 );
402
403 // 9. Verify announcement_y was synced to relay_a (from discovered relay_c)
404 let filter_y = Filter::new()
405 .kind(Kind::GitRepoAnnouncement)
406 .author(keys_y.public_key());
407
408 let announcement_y_synced =
409 wait_for_event_on_relay(relay_a.url(), filter_y, Duration::from_secs(5)).await;
410
411 println!(
412 "announcement_y {} synced to relay_a: {}",
413 announcement_y_id, announcement_y_synced
414 );
415
416 // 10. Cleanup
417 relay_a.stop().await;
418 relay_b.stop().await;
419 relay_c.stop().await;
420
421 // 11. Assertions
422 assert!(
423 announcement_x_synced,
424 "announcement_x {} should have synced from bootstrap relay_b to relay_a",
425 announcement_x_id
426 );
427
428 assert!(
429 announcement_y_synced,
430 "announcement_y {} should have synced from discovered relay_c to relay_a (recursive discovery)",
431 announcement_y_id
432 );
433}
diff --git a/tests/sync/historic_sync.rs b/tests/sync/historic_sync.rs
index aec2819..723b776 100644
--- a/tests/sync/historic_sync.rs
+++ b/tests/sync/historic_sync.rs
@@ -224,34 +224,24 @@ async fn test_history_sync_without_negentropy() {
224 // Create keys 224 // Create keys
225 let keys = Keys::generate(); 225 let keys = Keys::generate();
226 226
227 // Create announcement listing BOTH relay domains 227 // Set up announcement on source with git data
228 // This event will exist on source BEFORE syncing relay ever connects 228 // (purgatory requires git data before announcements are accepted)
229 let announcement = create_repo_announcement( 229 let domains = vec![source.domain(), syncing_domain.clone()];
230 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
231 let (announcement, _git_dir) = setup_announcement_on_relay(
232 &source,
230 &keys, 233 &keys,
231 &[&source.domain(), &syncing_domain], 234 &domain_refs,
232 "test-repo-history-no-negentropy", 235 "test-repo-history-no-negentropy",
233 ); 236 )
237 .await;
234 let announcement_id = announcement.id; 238 let announcement_id = announcement.id;
235 239
236 println!( 240 println!(
237 "Created announcement {} (kind {})", 241 "Announcement {} set up on source with git data (event exists BEFORE syncing relay connects)",
238 announcement_id, 242 announcement_id
239 announcement.kind.as_u16()
240 ); 243 );
241 244
242 // Send announcement to source (event now exists BEFORE syncing relay connects)
243 let client = TestClient::new(source.url(), keys.clone())
244 .await
245 .expect("Failed to connect to source");
246
247 client
248 .send_event(&announcement)
249 .await
250 .expect("Failed to send announcement to source");
251 println!("Announcement sent to source (event exists BEFORE syncing relay connects)");
252
253 client.disconnect().await;
254
255 // Wait to ensure event is stored 245 // Wait to ensure event is stored
256 tokio::time::sleep(Duration::from_millis(500)).await; 246 tokio::time::sleep(Duration::from_millis(500)).await;
257 247
diff --git a/tests/sync/live_sync.rs b/tests/sync/live_sync.rs
index 8ee3119..4289004 100644
--- a/tests/sync/live_sync.rs
+++ b/tests/sync/live_sync.rs
@@ -56,43 +56,24 @@ async fn test_live_sync_layer2_events() {
56 // 3. Create test keys 56 // 3. Create test keys
57 let keys = Keys::generate(); 57 let keys = Keys::generate();
58 58
59 // 4. Create a repository announcement that lists BOTH relays 59 // 4. Create a repository announcement on both relays with git data
60 // (purgatory requires git data before announcements are accepted)
60 let repo_id = "test-repo-live-l2"; 61 let repo_id = "test-repo-live-l2";
61 let announcement = 62 let domains = vec![relay_a.domain(), relay_b.domain()];
62 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 63 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
63 64
64 println!( 65 let (_announcement, _git_dir_a) =
65 "Created announcement {} (kind {})", 66 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
66 announcement.id, 67 println!("Announcement set up on relay_a with git data");
67 announcement.kind.as_u16()
68 );
69
70 // 5. Send announcement to relay_a
71 let client_a = TestClient::new(relay_a.url(), keys.clone())
72 .await
73 .expect("Failed to connect to relay_a");
74
75 client_a
76 .send_event(&announcement)
77 .await
78 .expect("Failed to send announcement to relay_a");
79 println!("Announcement sent to relay_a");
80
81 // 6. Send announcement to relay_b (triggers discovery of relay_a)
82 let client_b = TestClient::new(relay_b.url(), keys.clone())
83 .await
84 .expect("Failed to connect to relay_b");
85 68
86 client_b 69 let (_announcement_b, _git_dir_b) =
87 .send_event(&announcement) 70 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
88 .await 71 println!("Announcement set up on relay_b with git data (triggers discovery)");
89 .expect("Failed to send announcement to relay_b");
90 println!("Announcement sent to relay_b (triggers discovery)");
91 72
92 // 7. Wait for discovery to complete 73 // 5. Wait for discovery to complete
93 tokio::time::sleep(Duration::from_secs(1)).await; 74 tokio::time::sleep(Duration::from_secs(1)).await;
94 75
95 // 8. Create and send a Layer 2 issue event (using helper) 76 // 6. Create and send a Layer 2 issue event (using helper)
96 let repo_coordinate = repo_coord(&keys, repo_id); 77 let repo_coordinate = repo_coord(&keys, repo_id);
97 let issue = build_layer2_issue_event(&keys, &repo_coordinate, "Test Issue for Live Sync") 78 let issue = build_layer2_issue_event(&keys, &repo_coordinate, "Test Issue for Live Sync")
98 .expect("Failed to create issue event"); 79 .expect("Failed to create issue event");
@@ -104,6 +85,10 @@ async fn test_live_sync_layer2_events() {
104 } 85 }
105 86
106 // Send issue to relay_a only 87 // Send issue to relay_a only
88 let client_a = TestClient::new(relay_a.url(), keys.clone())
89 .await
90 .expect("Failed to connect to relay_a");
91
107 client_a 92 client_a
108 .send_event(&issue) 93 .send_event(&issue)
109 .await 94 .await
@@ -111,7 +96,6 @@ async fn test_live_sync_layer2_events() {
111 println!("Issue sent to relay_a"); 96 println!("Issue sent to relay_a");
112 97
113 client_a.disconnect().await; 98 client_a.disconnect().await;
114 client_b.disconnect().await;
115 99
116 // 9. Wait and verify event syncs to relay_b 100 // 9. Wait and verify event syncs to relay_b
117 let filter = Filter::new() 101 let filter = Filter::new()
@@ -166,30 +150,19 @@ async fn test_live_sync_layer3_events() {
166 150
167 let keys = Keys::generate(); 151 let keys = Keys::generate();
168 152
169 // 2. Create and send repository announcement to both relays 153 // 2. Create and send repository announcement to both relays with git data
154 // (purgatory requires git data before announcements are accepted)
170 let repo_id = "test-repo-live-l3"; 155 let repo_id = "test-repo-live-l3";
171 let announcement = 156 let domains = vec![relay_a.domain(), relay_b.domain()];
172 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 157 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
173 158
174 let client_a = TestClient::new(relay_a.url(), keys.clone()) 159 let (_announcement, _git_dir_a) =
175 .await 160 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
176 .expect("Failed to connect to relay_a"); 161 println!("Announcement set up on relay_a with git data");
177 162
178 let client_b = TestClient::new(relay_b.url(), keys.clone()) 163 let (_announcement_b, _git_dir_b) =
179 .await 164 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
180 .expect("Failed to connect to relay_b"); 165 println!("Announcement set up on relay_b with git data (triggers discovery)");
181
182 client_a
183 .send_event(&announcement)
184 .await
185 .expect("Failed to send announcement to relay_a");
186 println!("Announcement sent to relay_a");
187
188 client_b
189 .send_event(&announcement)
190 .await
191 .expect("Failed to send announcement to relay_b");
192 println!("Announcement sent to relay_b (triggers discovery)");
193 166
194 // 3. Wait for discovery 167 // 3. Wait for discovery
195 tokio::time::sleep(Duration::from_secs(1)).await; 168 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -200,6 +173,10 @@ async fn test_live_sync_layer3_events() {
200 .expect("Failed to create issue"); 173 .expect("Failed to create issue");
201 let issue_id = issue.id; 174 let issue_id = issue.id;
202 175
176 let client_a = TestClient::new(relay_a.url(), keys.clone())
177 .await
178 .expect("Failed to connect to relay_a");
179
203 client_a 180 client_a
204 .send_event(&issue) 181 .send_event(&issue)
205 .await 182 .await
@@ -243,7 +220,6 @@ async fn test_live_sync_layer3_events() {
243 println!("Issue synced to relay_b: {}", issue_synced); 220 println!("Issue synced to relay_b: {}", issue_synced);
244 221
245 client_a.disconnect().await; 222 client_a.disconnect().await;
246 client_b.disconnect().await;
247 223
248 // 7. Wait and verify comment syncs to relay_b 224 // 7. Wait and verify comment syncs to relay_b
249 let comment_filter = Filter::new() 225 let comment_filter = Filter::new()
@@ -343,29 +319,17 @@ async fn test_live_sync_event_ordering() {
343 319
344 let keys = Keys::generate(); 320 let keys = Keys::generate();
345 321
346 // 2. Create and send repository announcement to both relays 322 // 2. Create and send repository announcement to both relays with git data
323 // (purgatory requires git data before announcements are accepted)
347 let repo_id = "test-repo-ordering"; 324 let repo_id = "test-repo-ordering";
348 let announcement = 325 let domains = vec![relay_a.domain(), relay_b.domain()];
349 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 326 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
350 327
351 let client_a = TestClient::new(relay_a.url(), keys.clone()) 328 let (_announcement, _git_dir_a) =
352 .await 329 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
353 .expect("Failed to connect to relay_a"); 330 let (_announcement_b, _git_dir_b) =
354 331 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
355 let client_b = TestClient::new(relay_b.url(), keys.clone()) 332 println!("Announcements set up on both relays with git data");
356 .await
357 .expect("Failed to connect to relay_b");
358
359 client_a
360 .send_event(&announcement)
361 .await
362 .expect("Failed to send announcement to relay_a");
363
364 client_b
365 .send_event(&announcement)
366 .await
367 .expect("Failed to send announcement to relay_b");
368 println!("Announcements sent to both relays");
369 333
370 // 3. Wait for discovery 334 // 3. Wait for discovery
371 tokio::time::sleep(Duration::from_secs(1)).await; 335 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -375,6 +339,10 @@ async fn test_live_sync_event_ordering() {
375 let mut issue_ids = Vec::new(); 339 let mut issue_ids = Vec::new();
376 let mut expected_order_timestamps = Vec::new(); 340 let mut expected_order_timestamps = Vec::new();
377 341
342 let client_a = TestClient::new(relay_a.url(), keys.clone())
343 .await
344 .expect("Failed to connect to relay_a");
345
378 for i in 1..=3 { 346 for i in 1..=3 {
379 let issue = build_layer2_issue_event( 347 let issue = build_layer2_issue_event(
380 &keys, 348 &keys,
@@ -402,7 +370,6 @@ async fn test_live_sync_event_ordering() {
402 } 370 }
403 371
404 client_a.disconnect().await; 372 client_a.disconnect().await;
405 client_b.disconnect().await;
406 373
407 // 5. Wait for all events to sync 374 // 5. Wait for all events to sync
408 tokio::time::sleep(Duration::from_secs(3)).await; 375 tokio::time::sleep(Duration::from_secs(3)).await;
diff --git a/tests/sync/maintainer_reprocessing.rs b/tests/sync/maintainer_reprocessing.rs
index df1bf78..ff1eb43 100644
--- a/tests/sync/maintainer_reprocessing.rs
+++ b/tests/sync/maintainer_reprocessing.rs
@@ -2,6 +2,25 @@
2//! 2//!
3//! Tests the two-tier rejected events index and immediate re-processing of 3//! Tests the two-tier rejected events index and immediate re-processing of
4//! maintainer announcements when owner announcements are accepted. 4//! maintainer announcements when owner announcements are accepted.
5//!
6//! ## Test design
7//!
8//! Announcements now require git data before they are released from purgatory and
9//! served to other relays. The hot-cache re-processing path we want to exercise is:
10//!
11//! relay_b syncs maintainer announcement from relay_a
12//! → write policy rejects it (no owner announcement in DB yet)
13//! → event stored in hot cache
14//! owner git push to relay_b promotes owner announcement from purgatory
15//! → our new code calls rejected_events_index.invalidate_and_get()
16//! → maintainer announcement re-processed and accepted
17//!
18//! To guarantee the maintainer announcements arrive at relay_b *before* the owner
19//! git push, relay_b is started with relay_a as its bootstrap relay. That way
20//! relay_b's SyncManager connects to relay_a immediately and syncs whatever is
21//! already in relay_a's DB. We push the maintainer git data first (so the
22//! announcements are in relay_a's DB), wait briefly for the sync round-trip, then
23//! send the owner announcement + git push.
5 24
6use std::time::Duration; 25use std::time::Duration;
7 26
@@ -9,66 +28,91 @@ use nostr_sdk::prelude::*;
9 28
10use crate::common::{sync_helpers::*, TestRelay}; 29use crate::common::{sync_helpers::*, TestRelay};
11 30
12/// Test that maintainer announcements are re-processed immediately when owner announcement accepted 31/// Test that a maintainer announcement is re-processed immediately when the owner
32/// announcement is promoted from purgatory via a git push.
13/// 33///
14/// Flow: 34/// Flow:
15/// 1. relay_a: Maintainer sends announcement (gets rejected - doesn't list relay_b) 35/// 1. relay_a: Maintainer sends announcement + git data → accepted into relay_a's DB
16/// 2. relay_b: Owner sends announcement (lists relay_a + maintainer) 36/// 2. relay_b (bootstrapped from relay_a): SyncManager syncs maintainer announcement
17/// 3. relay_b syncs from relay_a, maintainer announcement enters rejected index 37/// → rejected by write policy (no owner in DB) → stored in hot cache
18/// 4. relay_b processes owner announcement, invalidates and re-processes maintainer announcement 38/// 3. relay_b: Owner sends announcement → purgatory (no git data yet)
39/// 4. relay_b: Owner git push → owner announcement promoted from purgatory
40/// → hot-cache re-processing fires → maintainer announcement accepted
19/// 5. Both announcements should be in relay_b's database 41/// 5. Both announcements should be in relay_b's database
20///
21/// Expected time: <5 seconds (vs 24 hours without hot cache)
22#[tokio::test] 42#[tokio::test]
23async fn test_maintainer_announcement_reprocessed_immediately() { 43async fn test_maintainer_announcement_reprocessed_immediately() {
24 // Start relay_a (where maintainer announcement will be sent) 44 // Start relay_a (where maintainer announcement will be sent)
25 let relay_a = TestRelay::start().await; 45 let relay_a = TestRelay::start().await;
26 println!("relay_a started at {}", relay_a.url()); 46 println!("relay_a started at {}", relay_a.url());
27 47
28 // Start relay_b with sync enabled (will sync from relay_a)
29 let relay_b = TestRelay::start_with_sync(None).await;
30 println!("relay_b started at {}", relay_b.url());
31
32 // Create keys 48 // Create keys
33 let owner_keys = Keys::generate(); 49 let owner_keys = Keys::generate();
34 let maintainer_keys = Keys::generate(); 50 let maintainer_keys = Keys::generate();
35
36 let identifier = "test-repo"; 51 let identifier = "test-repo";
37 52
38 let start = std::time::Instant::now(); 53 // Step 1: Send maintainer announcement to relay_a then push git data so it lands in
39 54 // relay_a's DB. The announcement lists relay_a only (not relay_b), so relay_b's write
40 // Step 1: Send maintainer announcement to relay_a (will be rejected - doesn't list relay_b) 55 // policy will reject it when it arrives via sync.
41 let client_a = TestClient::new(relay_a.url(), maintainer_keys.clone()) 56 let maintainer_npub = maintainer_keys
42 .await 57 .public_key()
43 .expect("Failed to connect to relay_a"); 58 .to_bech32()
44 59 .expect("Failed to get npub");
45 let maintainer_announcement = 60 let maintainer_announcement =
46 EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository") 61 EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository")
47 .tags(vec![ 62 .tags(vec![
48 Tag::identifier(identifier), 63 Tag::identifier(identifier),
49 Tag::custom( 64 Tag::custom(
50 TagKind::custom("clone"), 65 TagKind::custom("clone"),
51 vec![format!("https://{}/{}.git", relay_a.domain(), identifier)], 66 vec![format!(
67 "http://{}/{}/{}.git",
68 relay_a.domain(),
69 maintainer_npub,
70 identifier
71 )],
72 ),
73 Tag::custom(
74 TagKind::custom("relays"),
75 vec![relay_a.url().to_string()],
52 ), 76 ),
53 Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]),
54 ]) 77 ])
55 .sign_with_keys(&maintainer_keys) 78 .sign_with_keys(&maintainer_keys)
56 .unwrap(); 79 .unwrap();
80 send_to_relay(&relay_a, &maintainer_announcement).await.unwrap();
81 let _git_dir_maintainer =
82 push_git_data_to_relay(&relay_a, &maintainer_keys, identifier, &[&relay_a.domain()])
83 .await;
84 println!("✓ Maintainer announcement + git data pushed to relay_a");
85
86 // Step 2: Start relay_b with relay_a as bootstrap so its SyncManager connects immediately.
87 // relay_b's initial negentropy sync will pick up the maintainer announcement and reject it
88 // (no owner announcement in relay_b's DB yet), storing it in the hot cache.
89 let relay_b = TestRelay::start_with_sync(Some(relay_a.url().to_string())).await;
90 println!("relay_b started at {}", relay_b.url());
57 91
58 client_a.send_event(&maintainer_announcement).await.unwrap(); 92 // Give relay_b's SyncManager time to complete the initial negentropy sync with relay_a.
59 println!("✓ Maintainer announcement sent to relay_a"); 93 tokio::time::sleep(Duration::from_secs(3)).await;
94 println!("✓ relay_b synced from relay_a (maintainer announcement should be in hot cache)");
60 95
61 // Step 2: Send owner announcement to relay_b (lists relay_a + maintainer) 96 let start = std::time::Instant::now();
62 let client_b = TestClient::new(relay_b.url(), owner_keys.clone()) 97
63 .await 98 // Step 3: Send owner announcement to relay_b → goes to purgatory (no git data yet).
64 .expect("Failed to connect to relay_b"); 99 // The announcement lists relay_a + relay_b and names the maintainer.
100 let owner_npub = owner_keys
101 .public_key()
102 .to_bech32()
103 .expect("Failed to get npub");
65 104
66 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") 105 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository")
67 .tags(vec![ 106 .tags(vec![
68 Tag::identifier(identifier), 107 Tag::identifier(identifier),
69 Tag::custom( 108 Tag::custom(
70 TagKind::custom("clone"), 109 TagKind::custom("clone"),
71 vec![format!("https://{}/{}.git", relay_b.domain(), identifier)], 110 vec![format!(
111 "http://{}/{}/{}.git",
112 relay_b.domain(),
113 owner_npub,
114 identifier
115 )],
72 ), 116 ),
73 Tag::custom( 117 Tag::custom(
74 TagKind::custom("relays"), 118 TagKind::custom("relays"),
@@ -82,15 +126,22 @@ async fn test_maintainer_announcement_reprocessed_immediately() {
82 .sign_with_keys(&owner_keys) 126 .sign_with_keys(&owner_keys)
83 .unwrap(); 127 .unwrap();
84 128
85 client_b.send_event(&owner_announcement).await.unwrap(); 129 send_to_relay(&relay_b, &owner_announcement).await.unwrap();
86 println!("✓ Owner announcement sent to relay_b"); 130 println!("✓ Owner announcement sent to relay_b (now in purgatory)");
87 131
88 // Step 3: Wait for sync and re-processing (relay_b discovers relay_a, syncs, re-processes) 132 // Step 4: Push owner git data to relay_b.
89 tokio::time::sleep(Duration::from_secs(3)).await; 133 // This promotes the owner announcement from purgatory, which triggers hot-cache
134 // re-processing of the maintainer announcement via our new code path.
135 let _git_dir_owner =
136 push_git_data_to_relay(&relay_b, &owner_keys, identifier, &[&relay_b.domain()]).await;
137 println!("✓ Owner git data pushed to relay_b (owner announcement promoted, hot cache re-processed)");
138
139 // Step 5: Wait briefly for async processing to complete.
140 tokio::time::sleep(Duration::from_secs(1)).await;
90 141
91 let elapsed = start.elapsed(); 142 let elapsed = start.elapsed();
92 143
93 // Step 4: Verify both announcements are in relay_b's database 144 // Step 6: Verify both announcements are in relay_b's database.
94 let owner_filter = Filter::new() 145 let owner_filter = Filter::new()
95 .kind(Kind::GitRepoAnnouncement) 146 .kind(Kind::GitRepoAnnouncement)
96 .author(owner_keys.public_key()) 147 .author(owner_keys.public_key())
@@ -112,17 +163,14 @@ async fn test_maintainer_announcement_reprocessed_immediately() {
112 "Maintainer announcement should be re-processed and accepted in relay_b" 163 "Maintainer announcement should be re-processed and accepted in relay_b"
113 ); 164 );
114 165
115 // Step 5: Verify it happened quickly (not 24 hours!)
116 assert!( 166 assert!(
117 elapsed.as_secs() < 10, 167 elapsed.as_secs() < 15,
118 "Re-processing should happen in <10 seconds, took {:?}", 168 "Re-processing should happen in <15 seconds, took {:?}",
119 elapsed 169 elapsed
120 ); 170 );
121 171
122 println!("✅ Maintainer announcement re-processed in {:?}", elapsed); 172 println!("✅ Maintainer announcement re-processed in {:?}", elapsed);
123 173
124 client_a.disconnect().await;
125 client_b.disconnect().await;
126 relay_a.stop().await; 174 relay_a.stop().await;
127 relay_b.stop().await; 175 relay_b.stop().await;
128} 176}
@@ -227,13 +275,16 @@ async fn test_maintainer_announcement_cold_index_prevents_refetch() {
227 relay.stop().await; 275 relay.stop().await;
228} 276}
229 277
230/// Test multiple maintainers are all re-processed when owner announcement accepted 278/// Test that all maintainer announcements are re-processed when the owner announcement
279/// is promoted from purgatory via a git push.
231/// 280///
232/// Flow: 281/// Flow:
233/// 1. relay_a: Three maintainers send announcements (get rejected - don't list relay_b) 282/// 1. relay_a: Three maintainers send announcements + git data → in relay_a's DB
234/// 2. relay_b: Owner sends announcement (lists relay_a + all three maintainers) 283/// 2. relay_b (bootstrapped from relay_a): SyncManager syncs all three maintainer
235/// 3. relay_b syncs from relay_a, all maintainer announcements enter rejected index 284/// announcements → all rejected (no owner in DB) → all in hot cache
236/// 4. relay_b processes owner announcement, invalidates and re-processes all maintainer announcements 285/// 3. relay_b: Owner sends announcement → purgatory
286/// 4. relay_b: Owner git push → owner promoted → hot-cache re-processing fires for
287/// all three maintainers
237/// 5. All four announcements should be in relay_b's database 288/// 5. All four announcements should be in relay_b's database
238#[tokio::test] 289#[tokio::test]
239async fn test_multiple_maintainers_all_reprocessed() { 290async fn test_multiple_maintainers_all_reprocessed() {
@@ -241,57 +292,113 @@ async fn test_multiple_maintainers_all_reprocessed() {
241 let relay_a = TestRelay::start().await; 292 let relay_a = TestRelay::start().await;
242 println!("relay_a started at {}", relay_a.url()); 293 println!("relay_a started at {}", relay_a.url());
243 294
244 // Start relay_b with sync enabled (will sync from relay_a)
245 let relay_b = TestRelay::start_with_sync(None).await;
246 println!("relay_b started at {}", relay_b.url());
247
248 // Create keys 295 // Create keys
249 let owner_keys = Keys::generate(); 296 let owner_keys = Keys::generate();
250 let maintainer1_keys = Keys::generate(); 297 let maintainer1_keys = Keys::generate();
251 let maintainer2_keys = Keys::generate(); 298 let maintainer2_keys = Keys::generate();
252 let maintainer3_keys = Keys::generate(); 299 let maintainer3_keys = Keys::generate();
253 300
254 let identifier = "multi-maintainer-repo"; 301 // Use a unique identifier per test run to avoid cross-test interference when
255 302 // tests run in parallel (each test gets its own namespace on relay_a).
256 // Step 1: Send three maintainer announcements to relay_a 303 let identifier = &format!(
257 let client_a = TestClient::new(relay_a.url(), maintainer1_keys.clone()) 304 "multi-maintainer-repo-{}",
258 .await 305 owner_keys.public_key().to_hex()[..8].to_string()
259 .expect("Failed to connect to relay_a"); 306 );
260 307
308 // Step 1: Send each maintainer announcement to relay_a then push git data so all three
309 // land in relay_a's DB. Each announcement lists relay_a only, so relay_b will reject
310 // them when syncing (no owner announcement in relay_b's DB yet).
311 let mut git_dirs = Vec::new();
261 for (idx, maintainer_keys) in [&maintainer1_keys, &maintainer2_keys, &maintainer3_keys] 312 for (idx, maintainer_keys) in [&maintainer1_keys, &maintainer2_keys, &maintainer3_keys]
262 .iter() 313 .iter()
263 .enumerate() 314 .enumerate()
264 { 315 {
316 let m_npub = maintainer_keys
317 .public_key()
318 .to_bech32()
319 .expect("Failed to get npub");
265 let announcement = EventBuilder::new( 320 let announcement = EventBuilder::new(
266 Kind::GitRepoAnnouncement, 321 Kind::GitRepoAnnouncement,
267 format!("Maintainer {} repository", idx + 1), 322 format!("Maintainer {} repository", idx + 1),
268 ) 323 )
269 .tags(vec![ 324 .tags(vec![
270 Tag::identifier(identifier), 325 Tag::identifier(identifier.as_str()),
271 Tag::custom( 326 Tag::custom(
272 TagKind::custom("clone"), 327 TagKind::custom("clone"),
273 vec![format!("https://{}/{}.git", relay_a.domain(), identifier)], 328 vec![format!(
329 "http://{}/{}/{}.git",
330 relay_a.domain(),
331 m_npub,
332 identifier
333 )],
274 ), 334 ),
275 Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]), 335 Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]),
276 ]) 336 ])
277 .sign_with_keys(maintainer_keys) 337 .sign_with_keys(maintainer_keys)
278 .unwrap(); 338 .unwrap();
339 send_to_relay(&relay_a, &announcement).await.unwrap();
340 // Use push_unique_git_data_to_relay so each maintainer gets a distinct commit
341 // hash. Identical hashes cause git to skip pack transfer when the object
342 // already exists on the server, leaving the announcement in purgatory.
343 let git_dir = push_unique_git_data_to_relay(
344 &relay_a,
345 maintainer_keys,
346 identifier,
347 &[&relay_a.domain()],
348 &m_npub,
349 )
350 .await;
351 git_dirs.push(git_dir);
352 }
353 println!("✓ Three maintainer announcements + git data pushed to relay_a");
279 354
280 client_a.send_event(&announcement).await.unwrap(); 355 // Confirm all three announcements are queryable on relay_a before starting relay_b.
356 // This eliminates the race between relay_a's DB writes and relay_b's initial negentropy sync.
357 for (name, keys) in [
358 ("maintainer1", &maintainer1_keys),
359 ("maintainer2", &maintainer2_keys),
360 ("maintainer3", &maintainer3_keys),
361 ] {
362 let filter = Filter::new()
363 .kind(Kind::GitRepoAnnouncement)
364 .author(keys.public_key())
365 .identifier(identifier);
366 let found =
367 wait_for_event_on_relay(relay_a.url(), filter, Duration::from_secs(10)).await;
368 assert!(found, "{} announcement should be in relay_a before starting relay_b", name);
281 } 369 }
282 println!("✓ Three maintainer announcements sent to relay_a"); 370 println!("✓ All three maintainer announcements confirmed in relay_a's DB");
283 371
284 // Step 2: Send owner announcement to relay_b (lists relay_a + all three maintainers) 372 // Step 2: Start relay_b with relay_a as bootstrap so its SyncManager connects immediately.
285 let client_b = TestClient::new(relay_b.url(), owner_keys.clone()) 373 // Because all three maintainer announcements are confirmed in relay_a's DB, relay_b's
286 .await 374 // initial negentropy sync will pick them all up and reject them (no owner announcement
287 .expect("Failed to connect to relay_b"); 375 // in relay_b's DB yet), storing them in the hot cache.
376 let relay_b = TestRelay::start_with_sync(Some(relay_a.url().to_string())).await;
377 println!("relay_b started at {}", relay_b.url());
378
379 // Give relay_b's SyncManager time to complete the initial negentropy sync with relay_a.
380 // The negentropy sync completes within ~200ms (NGIT_TEST=1 sets batch window to 200ms), but we
381 // allow extra time for slow CI environments.
382 tokio::time::sleep(Duration::from_secs(3)).await;
383 println!("✓ relay_b synced from relay_a (maintainer announcements should be in hot cache)");
384
385 // Step 3: Send owner announcement to relay_b → goes to purgatory.
386 let owner_npub = owner_keys
387 .public_key()
388 .to_bech32()
389 .expect("Failed to get npub");
288 390
289 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") 391 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository")
290 .tags(vec![ 392 .tags(vec![
291 Tag::identifier(identifier), 393 Tag::identifier(identifier),
292 Tag::custom( 394 Tag::custom(
293 TagKind::custom("clone"), 395 TagKind::custom("clone"),
294 vec![format!("https://{}/{}.git", relay_b.domain(), identifier)], 396 vec![format!(
397 "http://{}/{}/{}.git",
398 relay_b.domain(),
399 owner_npub,
400 identifier
401 )],
295 ), 402 ),
296 Tag::custom( 403 Tag::custom(
297 TagKind::custom("relays"), 404 TagKind::custom("relays"),
@@ -309,13 +416,20 @@ async fn test_multiple_maintainers_all_reprocessed() {
309 .sign_with_keys(&owner_keys) 416 .sign_with_keys(&owner_keys)
310 .unwrap(); 417 .unwrap();
311 418
312 client_b.send_event(&owner_announcement).await.unwrap(); 419 send_to_relay(&relay_b, &owner_announcement).await.unwrap();
313 println!("✓ Owner announcement sent to relay_b"); 420 println!("✓ Owner announcement sent to relay_b (now in purgatory)");
314 421
315 // Step 3: Wait for sync and re-processing 422 // Step 4: Push owner git data to relay_b.
316 tokio::time::sleep(Duration::from_secs(3)).await; 423 // This promotes the owner announcement from purgatory and triggers hot-cache
424 // re-processing for all three maintainer announcements.
425 let _git_dir_owner =
426 push_git_data_to_relay(&relay_b, &owner_keys, identifier, &[&relay_b.domain()]).await;
427 println!("✓ Owner git data pushed to relay_b (hot-cache re-processing should fire)");
428
429 // Step 5: Wait briefly for async processing to complete.
430 tokio::time::sleep(Duration::from_secs(1)).await;
317 431
318 // Step 4: Verify all four announcements are in relay_b's database 432 // Step 6: Verify all four announcements are in relay_b's database.
319 for (name, keys) in [ 433 for (name, keys) in [
320 ("owner", &owner_keys), 434 ("owner", &owner_keys),
321 ("maintainer1", &maintainer1_keys), 435 ("maintainer1", &maintainer1_keys),
@@ -333,8 +447,6 @@ async fn test_multiple_maintainers_all_reprocessed() {
333 447
334 println!("✅ All three maintainer announcements re-processed successfully"); 448 println!("✅ All three maintainer announcements re-processed successfully");
335 449
336 client_a.disconnect().await;
337 client_b.disconnect().await;
338 relay_a.stop().await; 450 relay_a.stop().await;
339 relay_b.stop().await; 451 relay_b.stop().await;
340} 452}
@@ -342,10 +454,10 @@ async fn test_multiple_maintainers_all_reprocessed() {
342/// Test that invalid maintainer public keys don't cause panics 454/// Test that invalid maintainer public keys don't cause panics
343/// 455///
344/// Flow: 456/// Flow:
345/// 1. Maintainer announcement arrives → Rejected 457/// 1. Maintainer announcement arrives → Rejected (doesn't list our relay)
346/// 2. Owner announcement arrives with INVALID maintainer hex Should handle gracefully 458/// 2. Owner announcement + git push → accepted, with INVALID maintainer hex in maintainers tag
347/// 3. Owner announcement should still be accepted 459/// 3. Owner announcement should be accepted
348/// 4. Maintainer announcement should NOT be re-processed (invalid pubkey) 460/// 4. Maintainer announcement should NOT be re-processed (invalid pubkey can't be parsed)
349#[tokio::test] 461#[tokio::test]
350async fn test_invalid_maintainer_pubkey_handled_gracefully() { 462async fn test_invalid_maintainer_pubkey_handled_gracefully() {
351 let relay = TestRelay::start().await; 463 let relay = TestRelay::start().await;
@@ -382,13 +494,25 @@ async fn test_invalid_maintainer_pubkey_handled_gracefully() {
382 let _ = client.send_event(&maintainer_announcement).await; 494 let _ = client.send_event(&maintainer_announcement).await;
383 tokio::time::sleep(Duration::from_millis(200)).await; 495 tokio::time::sleep(Duration::from_millis(200)).await;
384 496
385 // Step 2: Send owner announcement with INVALID maintainer hex 497 // Step 2: Send owner announcement with INVALID maintainer hex, then push git data.
498 // The announcement goes to purgatory first; the git push promotes it.
499 // The invalid maintainer hex should be handled gracefully (no panic).
500 let owner_npub = owner_keys
501 .public_key()
502 .to_bech32()
503 .expect("Failed to get npub");
504
386 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") 505 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository")
387 .tags(vec![ 506 .tags(vec![
388 Tag::identifier(identifier), 507 Tag::identifier(identifier),
389 Tag::custom( 508 Tag::custom(
390 TagKind::custom("clone"), 509 TagKind::custom("clone"),
391 vec![format!("https://{}/{}.git", relay.domain(), identifier)], 510 vec![format!(
511 "http://{}/{}/{}.git",
512 relay.domain(),
513 owner_npub,
514 identifier
515 )],
392 ), 516 ),
393 Tag::custom(TagKind::custom("relays"), vec![relay.url().to_string()]), 517 Tag::custom(TagKind::custom("relays"), vec![relay.url().to_string()]),
394 Tag::custom( 518 Tag::custom(
@@ -399,7 +523,9 @@ async fn test_invalid_maintainer_pubkey_handled_gracefully() {
399 .sign_with_keys(&owner_keys) 523 .sign_with_keys(&owner_keys)
400 .unwrap(); 524 .unwrap();
401 525
402 client.send_event(&owner_announcement).await.unwrap(); 526 send_to_relay(&relay, &owner_announcement).await.unwrap();
527 let _git_dir =
528 push_git_data_to_relay(&relay, &owner_keys, identifier, &[&relay.domain()]).await;
403 tokio::time::sleep(Duration::from_millis(500)).await; 529 tokio::time::sleep(Duration::from_millis(500)).await;
404 530
405 // Step 3: Verify owner announcement accepted, maintainer not re-processed 531 // Step 3: Verify owner announcement accepted, maintainer not re-processed
diff --git a/tests/sync/metrics.rs b/tests/sync/metrics.rs
index e8c75c7..e973bbb 100644
--- a/tests/sync/metrics.rs
+++ b/tests/sync/metrics.rs
@@ -16,8 +16,8 @@ use nostr_sdk::prelude::*;
16 16
17use crate::common::{ 17use crate::common::{
18 sync_helpers::{ 18 sync_helpers::{
19 create_repo_announcement, fetch_metrics, wait_for_sync_connection, MetricsTestHarness, 19 create_repo_announcement, fetch_metrics, setup_announcement_on_relay,
20 ParsedMetrics, TestClient, 20 wait_for_sync_connection, MetricsTestHarness, ParsedMetrics, TestClient,
21 }, 21 },
22 TestRelay, 22 TestRelay,
23}; 23};
@@ -224,16 +224,17 @@ async fn test_startup_sync_event_count() {
224 // 3. Create test keys 224 // 3. Create test keys
225 let keys = Keys::generate(); 225 let keys = Keys::generate();
226 226
227 // 4. Create an announcement that lists BOTH relays (required for discovery) 227 // 4. Set up announcement on SOURCE relay with git data
228 let announcement = create_repo_announcement( 228 // (purgatory requires git data before announcements are accepted)
229 &keys, 229 let repo_id = "test-repo-metrics";
230 &[&source_relay.domain(), &syncing_relay.domain()], 230 let domains = vec![source_relay.domain(), syncing_relay.domain()];
231 "test-repo-metrics", 231 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
232 ); 232
233 let (announcement, _git_dir_source) =
234 setup_announcement_on_relay(&source_relay, &keys, &domain_refs, repo_id).await;
233 println!( 235 println!(
234 "Created announcement {} (kind {})", 236 "Announcement {} set up on source relay with git data",
235 announcement.id, 237 announcement.id
236 announcement.kind.as_u16()
237 ); 238 );
238 239
239 // 5. Build the repo coordinate for the 'a' tag in the patches 240 // 5. Build the repo coordinate for the 'a' tag in the patches
@@ -241,7 +242,7 @@ async fn test_startup_sync_event_count() {
241 "{}:{}:{}", 242 "{}:{}:{}",
242 Kind::GitRepoAnnouncement.as_u16(), 243 Kind::GitRepoAnnouncement.as_u16(),
243 keys.public_key().to_hex(), 244 keys.public_key().to_hex(),
244 "test-repo-metrics" 245 repo_id
245 ); 246 );
246 247
247 // 6. Create 3 patch events (Layer 2) that reference the announcement 248 // 6. Create 3 patch events (Layer 2) that reference the announcement
@@ -257,17 +258,11 @@ async fn test_startup_sync_event_count() {
257 .collect(); 258 .collect();
258 println!("Created {} patches", patches.len()); 259 println!("Created {} patches", patches.len());
259 260
260 // 7. Send announcement + patches to SOURCE relay ONLY 261 // 7. Send patches to SOURCE relay
261 let source_client = TestClient::new(source_relay.url(), keys.clone()) 262 let source_client = TestClient::new(source_relay.url(), keys.clone())
262 .await 263 .await
263 .expect("Failed to connect to source relay"); 264 .expect("Failed to connect to source relay");
264 265
265 source_client
266 .send_event(&announcement)
267 .await
268 .expect("Failed to send announcement to source");
269 println!("Announcement sent to source relay");
270
271 for patch in &patches { 266 for patch in &patches {
272 source_client 267 source_client
273 .send_event(patch) 268 .send_event(patch)
@@ -277,17 +272,10 @@ async fn test_startup_sync_event_count() {
277 println!("Patches sent to source relay"); 272 println!("Patches sent to source relay");
278 source_client.disconnect().await; 273 source_client.disconnect().await;
279 274
280 // 8. Send announcement to SYNCING relay (triggers discovery of source relay) 275 // 8. Set up announcement on SYNCING relay (triggers discovery of source relay)
281 let syncing_client = TestClient::new(syncing_relay.url(), keys.clone()) 276 let (_announcement_syncing, _git_dir_syncing) =
282 .await 277 setup_announcement_on_relay(&syncing_relay, &keys, &domain_refs, repo_id).await;
283 .expect("Failed to connect to syncing relay"); 278 println!("Announcement set up on syncing relay (triggers discovery of source)");
284
285 syncing_client
286 .send_event(&announcement)
287 .await
288 .expect("Failed to send announcement to syncing relay");
289 println!("Announcement sent to syncing relay (triggers discovery of source)");
290 syncing_client.disconnect().await;
291 279
292 // 9. Wait for discovery + sync to complete 280 // 9. Wait for discovery + sync to complete
293 println!("Waiting 5s for discovery and sync..."); 281 println!("Waiting 5s for discovery and sync...");
@@ -404,18 +392,35 @@ async fn test_connection_failure_increments_counter() {
404/// Test that live sync events are counted in metrics. 392/// Test that live sync events are counted in metrics.
405/// 393///
406/// This test validates that events received via live subscription 394/// This test validates that events received via live subscription
407/// (after sync connection is established) are counted separately 395/// (after sync connection is established) are counted in metrics.
408/// from startup/bootstrap events. 396/// Uses Layer 2 patch events (not announcements) to avoid purgatory,
397/// since Layer 2 events are accepted directly to the DB.
409#[tokio::test] 398#[tokio::test]
410async fn test_live_sync_event_count() { 399async fn test_live_sync_event_count() {
411 let mut harness = MetricsTestHarness::with_sources(1).await;
412
413 // Pre-allocate syncing relay port to include in announcements 400 // Pre-allocate syncing relay port to include in announcements
414 let sync_port = TestRelay::find_free_port(); 401 let sync_port = TestRelay::find_free_port();
415 let sync_domain = format!("127.0.0.1:{}", sync_port); 402 let sync_domain = format!("127.0.0.1:{}", sync_port);
416 403
404 // Start source relay
405 let source_relay = TestRelay::start().await;
406 println!("Source relay started at {}", source_relay.url());
407
408 // Set up announcement on source relay BEFORE starting syncing relay
409 // This allows discovery when syncing relay connects
410 let keys = Keys::generate();
411 let repo_id = "live-metrics-repo";
412 let domains = vec![source_relay.domain(), sync_domain.clone()];
413 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
414
415 let (_announcement, _git_dir) =
416 setup_announcement_on_relay(&source_relay, &keys, &domain_refs, repo_id).await;
417 println!("Announcement set up on source relay with git data");
418
417 // Start syncing relay with pre-allocated port 419 // Start syncing relay with pre-allocated port
418 harness.start_syncing_relay_on_port(0, sync_port).await; 420 let syncing_relay =
421 TestRelay::start_on_port_with_options(sync_port, Some(source_relay.url().to_string()), false)
422 .await;
423 println!("Syncing relay started at {}", syncing_relay.url());
419 424
420 // Wait for sync connection to be fully established with EOSE received 425 // Wait for sync connection to be fully established with EOSE received
421 // This ensures we're in "live" mode before submitting test events 426 // This ensures we're in "live" mode before submitting test events
@@ -424,33 +429,61 @@ async fn test_live_sync_event_count() {
424 .await 429 .await
425 .expect("Sync connection should be established"); 430 .expect("Sync connection should be established");
426 431
427 // Additional small delay to ensure EOSE has been processed 432 // Additional delay to ensure purgatory promotion completes on syncing relay
428 tokio::time::sleep(Duration::from_millis(500)).await; 433 tokio::time::sleep(Duration::from_secs(4)).await;
429 434
430 // Now add events - these should be "live" not "startup" 435 // Now add Layer 2 patch events (not announcements) - these are accepted immediately
431 // Include BOTH domains so events are accepted by both relays 436 // (Layer 2 events are accepted directly to DB, no purgatory)
432 let keys = Keys::generate(); 437 let repo_coord_str = format!(
433 let events: Vec<_> = (0..2) 438 "{}:{}:{}",
434 .map(|i| { 439 Kind::GitRepoAnnouncement.as_u16(),
435 create_repo_announcement( 440 keys.public_key().to_hex(),
436 &keys, 441 repo_id
437 &[&harness.source_domain(0), &sync_domain], 442 );
438 &format!("live-{}", i), 443
439 ) 444 let patch1 = create_event_referencing_repo(
440 }) 445 &keys,
441 .collect(); 446 &repo_coord_str,
442 harness.submit_events(0, &events).await.unwrap(); 447 Kind::GitPatch.as_u16(),
448 "Live test patch 1",
449 );
450 let patch2 = create_event_referencing_repo(
451 &keys,
452 &repo_coord_str,
453 Kind::GitPatch.as_u16(),
454 "Live test patch 2",
455 );
456
457 // Send patches to source AFTER sync connection established (live mode)
458 let client = TestClient::new(source_relay.url(), keys.clone())
459 .await
460 .expect("Failed to connect to source");
461 client.send_event(&patch1).await.expect("Failed to send patch 1");
462 client.send_event(&patch2).await.expect("Failed to send patch 2");
463 client.disconnect().await;
464 println!("Two patches sent to source relay (live mode)");
443 465
444 // Wait for live events to be processed and metrics updated 466 // Wait for live events to be processed and metrics updated
445 tokio::time::sleep(Duration::from_secs(4)).await; 467 tokio::time::sleep(Duration::from_secs(4)).await;
446 let metrics = harness.get_metrics().await.unwrap(); 468
469 // Fetch metrics from syncing relay
470 let raw_metrics = fetch_metrics(&sync_url)
471 .await
472 .expect("Failed to fetch metrics");
473 let metrics = ParsedMetrics::parse(&raw_metrics);
447 474
448 let synced_count = metrics.events_synced_total(); 475 let synced_count = metrics.events_synced_total();
449 println!("Events synced total: {:?}", synced_count); 476 println!("Events synced total: {:?}", synced_count);
450 477
451 assert_eq!(synced_count, Some(2), "Should have 2 synced events"); 478 // Cleanup
479 syncing_relay.stop().await;
480 source_relay.stop().await;
452 481
453 harness.stop_all().await; 482 assert!(
483 synced_count.is_some() && synced_count.unwrap() >= 2,
484 "Should have synced at least 2 events, got {:?}",
485 synced_count
486 );
454} 487}
455 488
456/// Test that relay connected status is tracked in metrics. 489/// Test that relay connected status is tracked in metrics.
diff --git a/tests/sync/mod.rs b/tests/sync/mod.rs
index 400341f..70c6981 100644
--- a/tests/sync/mod.rs
+++ b/tests/sync/mod.rs
@@ -82,14 +82,12 @@
82//! **Example from `discovery.rs`:** 82//! **Example from `discovery.rs`:**
83//! ```rust 83//! ```rust
84//! #[tokio::test] 84//! #[tokio::test]
85//! async fn test_recursive_relay_discovery() { 85//! async fn test_discovers_layer3_via_layer2() {
86//! // Multi-relay orchestration 86//! // Multi-relay orchestration
87//! let relay1 = TestRelay::start().await; 87//! let relay_a = TestRelay::start().await;
88//! let relay2 = TestRelay::start().await; 88//! let relay_b = TestRelay::start_with_sync(None).await;
89//! let relay3 = TestRelay::start().await;
90//! 89//!
91//! // relay1 announces relay2, relay2 announces relay3 90//! // relay_b receives announcement listing relay_a, discovers and syncs from it
92//! // Verify relay1 discovers relay3 through chain
93//! } 91//! }
94//! ``` 92//! ```
95//! 93//!
diff --git a/tests/sync/tag_variations.rs b/tests/sync/tag_variations.rs
index 46b1203..021ad0e 100644
--- a/tests/sync/tag_variations.rs
+++ b/tests/sync/tag_variations.rs
@@ -55,30 +55,19 @@ async fn test_layer2_sync_with_lowercase_a_tag() {
55 55
56 let keys = Keys::generate(); 56 let keys = Keys::generate();
57 57
58 // 2. Create and send repository announcement to both relays 58 // 2. Create and send repository announcement to both relays with git data
59 // (purgatory requires git data before announcements are accepted)
59 let repo_id = "test-repo-tag-8a"; 60 let repo_id = "test-repo-tag-8a";
60 let announcement = 61 let domains = vec![relay_a.domain(), relay_b.domain()];
61 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 62 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
62 63
63 let client_a = TestClient::new(relay_a.url(), keys.clone()) 64 let (_announcement, _git_dir_a) =
64 .await 65 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
65 .expect("Failed to connect to relay_a"); 66 println!("Announcement set up on relay_a with git data");
66
67 let client_b = TestClient::new(relay_b.url(), keys.clone())
68 .await
69 .expect("Failed to connect to relay_b");
70 67
71 client_a 68 let (_announcement_b, _git_dir_b) =
72 .send_event(&announcement) 69 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
73 .await 70 println!("Announcement set up on relay_b with git data (triggers discovery)");
74 .expect("Failed to send announcement to relay_a");
75 println!("Announcement sent to relay_a");
76
77 client_b
78 .send_event(&announcement)
79 .await
80 .expect("Failed to send announcement to relay_b");
81 println!("Announcement sent to relay_b (triggers discovery)");
82 71
83 // 3. Wait for discovery 72 // 3. Wait for discovery
84 tokio::time::sleep(Duration::from_secs(1)).await; 73 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -95,9 +84,10 @@ async fn test_layer2_sync_with_lowercase_a_tag() {
95 issue_id, 84 issue_id,
96 issue.kind.as_u16() 85 issue.kind.as_u16()
97 ); 86 );
98 for tag in issue.tags.iter() { 87
99 println!(" Tag: {:?}", tag.as_slice()); 88 let client_a = TestClient::new(relay_a.url(), keys.clone())
100 } 89 .await
90 .expect("Failed to connect to relay_a");
101 91
102 client_a 92 client_a
103 .send_event(&issue) 93 .send_event(&issue)
@@ -106,7 +96,6 @@ async fn test_layer2_sync_with_lowercase_a_tag() {
106 println!("Issue sent to relay_a"); 96 println!("Issue sent to relay_a");
107 97
108 client_a.disconnect().await; 98 client_a.disconnect().await;
109 client_b.disconnect().await;
110 99
111 // 5. Wait and verify event syncs to relay_b 100 // 5. Wait and verify event syncs to relay_b
112 let filter = Filter::new() 101 let filter = Filter::new()
@@ -154,30 +143,18 @@ async fn test_layer2_sync_with_uppercase_a_tag() {
154 143
155 let keys = Keys::generate(); 144 let keys = Keys::generate();
156 145
157 // 2. Create and send repository announcement to both relays 146 // 2. Create and send repository announcement to both relays with git data
158 let repo_id = "test-repo-tag-8b"; 147 let repo_id = "test-repo-tag-8b";
159 let announcement = 148 let domains = vec![relay_a.domain(), relay_b.domain()];
160 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 149 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
161
162 let client_a = TestClient::new(relay_a.url(), keys.clone())
163 .await
164 .expect("Failed to connect to relay_a");
165 150
166 let client_b = TestClient::new(relay_b.url(), keys.clone()) 151 let (_announcement, _git_dir_a) =
167 .await 152 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
168 .expect("Failed to connect to relay_b"); 153 println!("Announcement set up on relay_a with git data");
169 154
170 client_a 155 let (_announcement_b, _git_dir_b) =
171 .send_event(&announcement) 156 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
172 .await 157 println!("Announcement set up on relay_b with git data (triggers discovery)");
173 .expect("Failed to send announcement to relay_a");
174 println!("Announcement sent to relay_a");
175
176 client_b
177 .send_event(&announcement)
178 .await
179 .expect("Failed to send announcement to relay_b");
180 println!("Announcement sent to relay_b (triggers discovery)");
181 158
182 // 3. Wait for discovery 159 // 3. Wait for discovery
183 tokio::time::sleep(Duration::from_secs(1)).await; 160 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -197,9 +174,10 @@ async fn test_layer2_sync_with_uppercase_a_tag() {
197 issue_id, 174 issue_id,
198 issue.kind.as_u16() 175 issue.kind.as_u16()
199 ); 176 );
200 for tag in issue.tags.iter() { 177
201 println!(" Tag: {:?}", tag.as_slice()); 178 let client_a = TestClient::new(relay_a.url(), keys.clone())
202 } 179 .await
180 .expect("Failed to connect to relay_a");
203 181
204 client_a 182 client_a
205 .send_event(&issue) 183 .send_event(&issue)
@@ -208,7 +186,6 @@ async fn test_layer2_sync_with_uppercase_a_tag() {
208 println!("Issue sent to relay_a"); 186 println!("Issue sent to relay_a");
209 187
210 client_a.disconnect().await; 188 client_a.disconnect().await;
211 client_b.disconnect().await;
212 189
213 // 5. Wait and verify event syncs to relay_b 190 // 5. Wait and verify event syncs to relay_b
214 let filter = Filter::new() 191 let filter = Filter::new()
@@ -255,30 +232,18 @@ async fn test_layer2_sync_with_q_tag() {
255 232
256 let keys = Keys::generate(); 233 let keys = Keys::generate();
257 234
258 // 2. Create and send repository announcement to both relays 235 // 2. Create and send repository announcement to both relays with git data
259 let repo_id = "test-repo-tag-8c"; 236 let repo_id = "test-repo-tag-8c";
260 let announcement = 237 let domains = vec![relay_a.domain(), relay_b.domain()];
261 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 238 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
262 239
263 let client_a = TestClient::new(relay_a.url(), keys.clone()) 240 let (_announcement, _git_dir_a) =
264 .await 241 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
265 .expect("Failed to connect to relay_a"); 242 println!("Announcement set up on relay_a with git data");
266 243
267 let client_b = TestClient::new(relay_b.url(), keys.clone()) 244 let (_announcement_b, _git_dir_b) =
268 .await 245 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
269 .expect("Failed to connect to relay_b"); 246 println!("Announcement set up on relay_b with git data (triggers discovery)");
270
271 client_a
272 .send_event(&announcement)
273 .await
274 .expect("Failed to send announcement to relay_a");
275 println!("Announcement sent to relay_a");
276
277 client_b
278 .send_event(&announcement)
279 .await
280 .expect("Failed to send announcement to relay_b");
281 println!("Announcement sent to relay_b (triggers discovery)");
282 247
283 // 3. Wait for discovery 248 // 3. Wait for discovery
284 tokio::time::sleep(Duration::from_secs(1)).await; 249 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -294,9 +259,10 @@ async fn test_layer2_sync_with_q_tag() {
294 issue_id, 259 issue_id,
295 issue.kind.as_u16() 260 issue.kind.as_u16()
296 ); 261 );
297 for tag in issue.tags.iter() { 262
298 println!(" Tag: {:?}", tag.as_slice()); 263 let client_a = TestClient::new(relay_a.url(), keys.clone())
299 } 264 .await
265 .expect("Failed to connect to relay_a");
300 266
301 client_a 267 client_a
302 .send_event(&issue) 268 .send_event(&issue)
@@ -305,7 +271,6 @@ async fn test_layer2_sync_with_q_tag() {
305 println!("Issue sent to relay_a"); 271 println!("Issue sent to relay_a");
306 272
307 client_a.disconnect().await; 273 client_a.disconnect().await;
308 client_b.disconnect().await;
309 274
310 // 5. Wait and verify event syncs to relay_b 275 // 5. Wait and verify event syncs to relay_b
311 let filter = Filter::new() 276 let filter = Filter::new()
@@ -362,30 +327,18 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
362 327
363 let keys = Keys::generate(); 328 let keys = Keys::generate();
364 329
365 // 2. Create and send repository announcement to both relays 330 // 2. Create and send repository announcement to both relays with git data
366 let repo_id = "test-repo-tag-9a"; 331 let repo_id = "test-repo-tag-9a";
367 let announcement = 332 let domains = vec![relay_a.domain(), relay_b.domain()];
368 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 333 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
369 334
370 let client_a = TestClient::new(relay_a.url(), keys.clone()) 335 let (_announcement, _git_dir_a) =
371 .await 336 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
372 .expect("Failed to connect to relay_a"); 337 println!("Announcement set up on relay_a with git data");
373
374 let client_b = TestClient::new(relay_b.url(), keys.clone())
375 .await
376 .expect("Failed to connect to relay_b");
377 338
378 client_a 339 let (_announcement_b, _git_dir_b) =
379 .send_event(&announcement) 340 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
380 .await 341 println!("Announcement set up on relay_b with git data (triggers discovery)");
381 .expect("Failed to send announcement to relay_a");
382 println!("Announcement sent to relay_a");
383
384 client_b
385 .send_event(&announcement)
386 .await
387 .expect("Failed to send announcement to relay_b");
388 println!("Announcement sent to relay_b (triggers discovery)");
389 342
390 // 3. Wait for discovery 343 // 3. Wait for discovery
391 tokio::time::sleep(Duration::from_secs(1)).await; 344 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -396,6 +349,10 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
396 .expect("Failed to create issue"); 349 .expect("Failed to create issue");
397 let issue_id = issue.id; 350 let issue_id = issue.id;
398 351
352 let client_a = TestClient::new(relay_a.url(), keys.clone())
353 .await
354 .expect("Failed to connect to relay_a");
355
399 client_a 356 client_a
400 .send_event(&issue) 357 .send_event(&issue)
401 .await 358 .await
@@ -410,11 +367,6 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
410 assert!(issue_synced, "Layer 2 issue should sync first"); 367 assert!(issue_synced, "Layer 2 issue should sync first");
411 368
412 // Wait for Layer 3 subscriptions to be established 369 // Wait for Layer 3 subscriptions to be established
413 // After issue syncs, relay_b's SelfSubscriber needs time to:
414 // 1. Receive the synced issue via notify_event broadcast
415 // 2. Batch timer to tick (up to 200ms in tests)
416 // 3. Process batch and create Layer 3 filters
417 // 4. Subscribe to relay_a with Layer 3 filters
418 tokio::time::sleep(Duration::from_millis(500)).await; 370 tokio::time::sleep(Duration::from_millis(500)).await;
419 371
420 // 6. Create and send Layer 3 reply with lowercase 'e' tag (kind 1) 372 // 6. Create and send Layer 3 reply with lowercase 'e' tag (kind 1)
@@ -427,9 +379,6 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
427 reply_id, 379 reply_id,
428 reply.kind.as_u16() 380 reply.kind.as_u16()
429 ); 381 );
430 for tag in reply.tags.iter() {
431 println!(" Tag: {:?}", tag.as_slice());
432 }
433 382
434 client_a 383 client_a
435 .send_event(&reply) 384 .send_event(&reply)
@@ -438,7 +387,6 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
438 println!("Layer 3 reply {} sent to relay_a", reply_id); 387 println!("Layer 3 reply {} sent to relay_a", reply_id);
439 388
440 client_a.disconnect().await; 389 client_a.disconnect().await;
441 client_b.disconnect().await;
442 390
443 // 7. Wait and verify reply syncs to relay_b 391 // 7. Wait and verify reply syncs to relay_b
444 let reply_filter = Filter::new() 392 let reply_filter = Filter::new()
@@ -486,30 +434,18 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
486 434
487 let keys = Keys::generate(); 435 let keys = Keys::generate();
488 436
489 // 2. Create and send repository announcement to both relays 437 // 2. Create and send repository announcement to both relays with git data
490 let repo_id = "test-repo-tag-9b"; 438 let repo_id = "test-repo-tag-9b";
491 let announcement = 439 let domains = vec![relay_a.domain(), relay_b.domain()];
492 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 440 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
493
494 let client_a = TestClient::new(relay_a.url(), keys.clone())
495 .await
496 .expect("Failed to connect to relay_a");
497 441
498 let client_b = TestClient::new(relay_b.url(), keys.clone()) 442 let (_announcement, _git_dir_a) =
499 .await 443 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
500 .expect("Failed to connect to relay_b"); 444 println!("Announcement set up on relay_a with git data");
501 445
502 client_a 446 let (_announcement_b, _git_dir_b) =
503 .send_event(&announcement) 447 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
504 .await 448 println!("Announcement set up on relay_b with git data (triggers discovery)");
505 .expect("Failed to send announcement to relay_a");
506 println!("Announcement sent to relay_a");
507
508 client_b
509 .send_event(&announcement)
510 .await
511 .expect("Failed to send announcement to relay_b");
512 println!("Announcement sent to relay_b (triggers discovery)");
513 449
514 // 3. Wait for discovery 450 // 3. Wait for discovery
515 tokio::time::sleep(Duration::from_secs(1)).await; 451 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -520,6 +456,10 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
520 .expect("Failed to create issue"); 456 .expect("Failed to create issue");
521 let issue_id = issue.id; 457 let issue_id = issue.id;
522 458
459 let client_a = TestClient::new(relay_a.url(), keys.clone())
460 .await
461 .expect("Failed to connect to relay_a");
462
523 client_a 463 client_a
524 .send_event(&issue) 464 .send_event(&issue)
525 .await 465 .await
@@ -534,11 +474,6 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
534 assert!(issue_synced, "Layer 2 issue should sync first"); 474 assert!(issue_synced, "Layer 2 issue should sync first");
535 475
536 // Wait for Layer 3 subscriptions to be established 476 // Wait for Layer 3 subscriptions to be established
537 // After issue syncs, relay_b's SelfSubscriber needs time to:
538 // 1. Receive the synced issue via notify_event broadcast
539 // 2. Batch timer to tick (up to 200ms in tests)
540 // 3. Process batch and create Layer 3 filters
541 // 4. Subscribe to relay_a with Layer 3 filters
542 tokio::time::sleep(Duration::from_millis(500)).await; 477 tokio::time::sleep(Duration::from_millis(500)).await;
543 478
544 // 6. Create and send Layer 3 comment with uppercase 'E' tag (kind 1111) 479 // 6. Create and send Layer 3 comment with uppercase 'E' tag (kind 1111)
@@ -552,9 +487,6 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
552 comment_id, 487 comment_id,
553 comment.kind.as_u16() 488 comment.kind.as_u16()
554 ); 489 );
555 for tag in comment.tags.iter() {
556 println!(" Tag: {:?}", tag.as_slice());
557 }
558 490
559 client_a 491 client_a
560 .send_event(&comment) 492 .send_event(&comment)
@@ -563,7 +495,6 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
563 println!("Layer 3 comment {} sent to relay_a", comment_id); 495 println!("Layer 3 comment {} sent to relay_a", comment_id);
564 496
565 client_a.disconnect().await; 497 client_a.disconnect().await;
566 client_b.disconnect().await;
567 498
568 // 7. Wait and verify comment syncs to relay_b 499 // 7. Wait and verify comment syncs to relay_b
569 let comment_filter = Filter::new() 500 let comment_filter = Filter::new()
@@ -614,30 +545,18 @@ async fn test_layer3_sync_with_q_tag() {
614 545
615 let keys = Keys::generate(); 546 let keys = Keys::generate();
616 547
617 // 2. Create and send repository announcement to both relays 548 // 2. Create and send repository announcement to both relays with git data
618 let repo_id = "test-repo-tag-9c"; 549 let repo_id = "test-repo-tag-9c";
619 let announcement = 550 let domains = vec![relay_a.domain(), relay_b.domain()];
620 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 551 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
621 552
622 let client_a = TestClient::new(relay_a.url(), keys.clone()) 553 let (_announcement, _git_dir_a) =
623 .await 554 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
624 .expect("Failed to connect to relay_a"); 555 println!("Announcement set up on relay_a with git data");
625 556
626 let client_b = TestClient::new(relay_b.url(), keys.clone()) 557 let (_announcement_b, _git_dir_b) =
627 .await 558 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
628 .expect("Failed to connect to relay_b"); 559 println!("Announcement set up on relay_b with git data (triggers discovery)");
629
630 client_a
631 .send_event(&announcement)
632 .await
633 .expect("Failed to send announcement to relay_a");
634 println!("Announcement sent to relay_a");
635
636 client_b
637 .send_event(&announcement)
638 .await
639 .expect("Failed to send announcement to relay_b");
640 println!("Announcement sent to relay_b (triggers discovery)");
641 560
642 // 3. Wait for discovery 561 // 3. Wait for discovery
643 tokio::time::sleep(Duration::from_secs(1)).await; 562 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -648,6 +567,10 @@ async fn test_layer3_sync_with_q_tag() {
648 .expect("Failed to create issue"); 567 .expect("Failed to create issue");
649 let issue_id = issue.id; 568 let issue_id = issue.id;
650 569
570 let client_a = TestClient::new(relay_a.url(), keys.clone())
571 .await
572 .expect("Failed to connect to relay_a");
573
651 client_a 574 client_a
652 .send_event(&issue) 575 .send_event(&issue)
653 .await 576 .await
@@ -662,11 +585,6 @@ async fn test_layer3_sync_with_q_tag() {
662 assert!(issue_synced, "Layer 2 issue should sync first"); 585 assert!(issue_synced, "Layer 2 issue should sync first");
663 586
664 // Wait for Layer 3 subscriptions to be established 587 // Wait for Layer 3 subscriptions to be established
665 // After issue syncs, relay_b's SelfSubscriber needs time to:
666 // 1. Receive the synced issue via notify_event broadcast
667 // 2. Batch timer to tick (up to 200ms in tests)
668 // 3. Process batch and create Layer 3 filters
669 // 4. Subscribe to relay_a with Layer 3 filters
670 tokio::time::sleep(Duration::from_millis(500)).await; 588 tokio::time::sleep(Duration::from_millis(500)).await;
671 589
672 // 6. Create and send Layer 3 quote with 'q' tag (kind 1) 590 // 6. Create and send Layer 3 quote with 'q' tag (kind 1)
@@ -679,9 +597,6 @@ async fn test_layer3_sync_with_q_tag() {
679 quote_id, 597 quote_id,
680 quote.kind.as_u16() 598 quote.kind.as_u16()
681 ); 599 );
682 for tag in quote.tags.iter() {
683 println!(" Tag: {:?}", tag.as_slice());
684 }
685 600
686 client_a 601 client_a
687 .send_event(&quote) 602 .send_event(&quote)
@@ -690,7 +605,6 @@ async fn test_layer3_sync_with_q_tag() {
690 println!("Layer 3 quote {} sent to relay_a", quote_id); 605 println!("Layer 3 quote {} sent to relay_a", quote_id);
691 606
692 client_a.disconnect().await; 607 client_a.disconnect().await;
693 client_b.disconnect().await;
694 608
695 // 7. Wait and verify quote syncs to relay_b 609 // 7. Wait and verify quote syncs to relay_b
696 let quote_filter = Filter::new() 610 let quote_filter = Filter::new()