upleb.uk

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

summaryrefslogtreecommitdiff
path: root/tests/state_authorization.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-09 17:04:06 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-09 17:04:06 +0000
commit5ecd8d6a434f97da94daef2f59166086fbaf5a6b (patch)
tree54c7d3b953a6b1aedd1db6b9a719e18131659df5 /tests/state_authorization.rs
parent895359aeb6746b98ff82944e4fca503f4a6e5439 (diff)
feat: implement state event authorization per GRASP-01 spec
Add comprehensive authorization checks to ensure state events are only accepted from maintainers of accepted repository announcements. This implements the core GRASP-01 requirement that pushes must match the latest state announcement "respecting the maintainer set." Changes: 1. StatePolicy authorization (src/nostr/policy/state.rs): - Check authorization BEFORE git data validation (fail-fast) - Reject if no announcement exists for repository - Reject if author not in maintainer set - Use existing helpers: fetch_repository_data() and pubkey_authorised_for_repo_owners() - Structured logging for all rejections 2. Purgatory invalidation (src/nostr/builder.rs): - New method: check_purgatory_state_events_for_identifier() - Called when announcements accepted (Accept and AcceptMaintainer) - Re-evaluates state events in purgatory for the identifier - Processes newly-authorized events (releases from purgatory) - Keeps unauthorized events for natural expiry (30 min) - Enables retroactive authorization when announcements arrive late 3. Purgatory sync authorization (src/git/sync.rs): - Check authorization BEFORE processing git data - Remove unauthorized events from purgatory (permanent rejection) - Prevents processing even if git data arrives first - Structured logging for monitoring 4. Rejected events tracking (src/sync/rejected_index.rs): - Add support for tracking rejected state events - New methods: add_state(), contains_state() - Separate metrics for state rejections - Enables sync to avoid re-fetching rejected states 5. Sync metrics (src/sync/metrics.rs, src/sync/mod.rs): - Add state-specific metrics (hot cache, cold index) - Track rejected states separately from announcements - Support monitoring of authorization rejections 6. Comprehensive tests (tests/state_authorization.rs): - test_reject_state_without_announcement - test_reject_state_from_unauthorized_author - test_accept_state_from_announcement_author - test_accept_state_from_maintainer Security Impact: - Before: State events could be published by anyone - After: Only maintainers can publish state events - Defense-in-depth: Authorization checked at 3 points: 1. On arrival (StatePolicy) 2. On announcement acceptance (purgatory re-evaluation) 3. On git data arrival (purgatory sync) All tests pass: - 248 unit tests - 51 NIP-34 announcement tests - 4 new state authorization tests - 9 rejected index tests Closes: State authorization requirement from GRASP-01 spec
Diffstat (limited to 'tests/state_authorization.rs')
-rw-r--r--tests/state_authorization.rs280
1 files changed, 280 insertions, 0 deletions
diff --git a/tests/state_authorization.rs b/tests/state_authorization.rs
new file mode 100644
index 0000000..a5dfa2d
--- /dev/null
+++ b/tests/state_authorization.rs
@@ -0,0 +1,280 @@
1//! Tests for state event authorization
2//!
3//! Verifies that state events are properly rejected when:
4//! 1. No announcement exists for the repository
5//! 2. Author is not in the maintainer set
6
7mod common;
8
9use common::relay::TestRelay;
10use nostr_sdk::prelude::*;
11
12#[tokio::test]
13async fn test_reject_state_without_announcement() {
14 // Start test relay
15 let relay = TestRelay::start().await;
16
17 // Create test keypair
18 let keys = Keys::generate();
19
20 // Create a state event without any announcement
21 let state_event = EventBuilder::new(
22 Kind::RepoState,
23 "",
24 )
25 .tags([
26 Tag::custom(TagKind::custom("d"), ["test-repo"]),
27 Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
28 ])
29 .sign_with_keys(&keys)
30 .unwrap();
31
32 // Connect to relay
33 let client = Client::default();
34 client.add_relay(relay.url()).await.unwrap();
35 client.connect().await;
36
37 // Try to send state event
38 let result = client.send_event(&state_event).await;
39
40 // Should be rejected
41 match result {
42 Ok(output) => {
43 assert!(
44 !output.success.is_empty() || !output.failed.is_empty(),
45 "Event should be processed"
46 );
47 // Check if any relay rejected it
48 let rejected = output.failed.values().any(|err| {
49 err.to_string().contains("no announcement exists")
50 });
51 assert!(rejected, "Event should be rejected due to missing announcement");
52 }
53 Err(e) => {
54 // Also acceptable - relay rejected the event
55 assert!(
56 e.to_string().contains("no announcement exists") ||
57 e.to_string().contains("rejected"),
58 "Error should indicate missing announcement: {}",
59 e
60 );
61 }
62 }
63
64 relay.stop().await;
65}
66
67#[tokio::test]
68async fn test_reject_state_from_unauthorized_author() {
69 // Start test relay
70 let relay = TestRelay::start().await;
71
72 // Create two keypairs: one for announcement, one for unauthorized state
73 let announcement_keys = Keys::generate();
74 let unauthorized_keys = Keys::generate();
75
76 // Create announcement
77 let announcement = EventBuilder::new(
78 Kind::GitRepoAnnouncement,
79 "",
80 )
81 .tags([
82 Tag::custom(TagKind::custom("d"), ["test-repo"]),
83 Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]),
84 Tag::custom(TagKind::custom("relays"), [relay.url()]),
85 ])
86 .sign_with_keys(&announcement_keys)
87 .unwrap();
88
89 // Connect to relay
90 let client = Client::default();
91 client.add_relay(relay.url()).await.unwrap();
92 client.connect().await;
93
94 // Send announcement
95 client.send_event(&announcement).await.unwrap();
96
97 // Wait for announcement to be processed
98 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
99
100 // Try to send state event from unauthorized author
101 let state_event = EventBuilder::new(
102 Kind::RepoState,
103 "",
104 )
105 .tags([
106 Tag::custom(TagKind::custom("d"), ["test-repo"]),
107 Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
108 ])
109 .sign_with_keys(&unauthorized_keys)
110 .unwrap();
111
112 let result = client.send_event(&state_event).await;
113
114 // Should be rejected
115 match result {
116 Ok(output) => {
117 let rejected = output.failed.values().any(|err| {
118 err.to_string().contains("not authorized")
119 });
120 assert!(rejected, "Event should be rejected due to unauthorized author");
121 }
122 Err(e) => {
123 assert!(
124 e.to_string().contains("not authorized") ||
125 e.to_string().contains("rejected"),
126 "Error should indicate unauthorized author: {}",
127 e
128 );
129 }
130 }
131
132 relay.stop().await;
133}
134
135#[tokio::test]
136async fn test_accept_state_from_announcement_author() {
137 // Start test relay
138 let relay = TestRelay::start().await;
139
140 // Create keypair
141 let keys = Keys::generate();
142
143 // Create announcement
144 let announcement = EventBuilder::new(
145 Kind::GitRepoAnnouncement,
146 "",
147 )
148 .tags([
149 Tag::custom(TagKind::custom("d"), ["test-repo"]),
150 Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]),
151 Tag::custom(TagKind::custom("relays"), [relay.url()]),
152 ])
153 .sign_with_keys(&keys)
154 .unwrap();
155
156 // Connect to relay
157 let client = Client::default();
158 client.add_relay(relay.url()).await.unwrap();
159 client.connect().await;
160
161 // Send announcement
162 client.send_event(&announcement).await.unwrap();
163
164 // Wait for announcement to be processed
165 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
166
167 // Send state event from same author (should be accepted or go to purgatory)
168 let state_event = EventBuilder::new(
169 Kind::RepoState,
170 "",
171 )
172 .tags([
173 Tag::custom(TagKind::custom("d"), ["test-repo"]),
174 Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
175 ])
176 .sign_with_keys(&keys)
177 .unwrap();
178
179 let result = client.send_event(&state_event).await;
180
181 // Should be accepted or go to purgatory (not permanently rejected)
182 match result {
183 Ok(output) => {
184 // Check that it wasn't permanently rejected
185 let permanently_rejected = output.failed.values().any(|err| {
186 let err_str = err.to_string();
187 err_str.contains("not authorized") || err_str.contains("no announcement exists")
188 });
189 assert!(
190 !permanently_rejected,
191 "Event should not be permanently rejected when author is authorized"
192 );
193 }
194 Err(e) => {
195 // Purgatory is acceptable
196 assert!(
197 e.to_string().contains("purgatory") ||
198 e.to_string().contains("waiting for git"),
199 "Error should be about purgatory, not authorization: {}",
200 e
201 );
202 }
203 }
204
205 relay.stop().await;
206}
207
208#[tokio::test]
209async fn test_accept_state_from_maintainer() {
210 // Start test relay
211 let relay = TestRelay::start().await;
212
213 // Create two keypairs: owner and maintainer
214 let owner_keys = Keys::generate();
215 let maintainer_keys = Keys::generate();
216
217 // Create announcement with maintainer
218 let announcement = EventBuilder::new(
219 Kind::GitRepoAnnouncement,
220 "",
221 )
222 .tags([
223 Tag::custom(TagKind::custom("d"), ["test-repo"]),
224 Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]),
225 Tag::custom(TagKind::custom("relays"), [relay.url()]),
226 Tag::custom(TagKind::custom("maintainers"), [maintainer_keys.public_key().to_hex()]),
227 ])
228 .sign_with_keys(&owner_keys)
229 .unwrap();
230
231 // Connect to relay
232 let client = Client::default();
233 client.add_relay(relay.url()).await.unwrap();
234 client.connect().await;
235
236 // Send announcement
237 client.send_event(&announcement).await.unwrap();
238
239 // Wait for announcement to be processed
240 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
241
242 // Send state event from maintainer
243 let state_event = EventBuilder::new(
244 Kind::RepoState,
245 "",
246 )
247 .tags([
248 Tag::custom(TagKind::custom("d"), ["test-repo"]),
249 Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
250 ])
251 .sign_with_keys(&maintainer_keys)
252 .unwrap();
253
254 let result = client.send_event(&state_event).await;
255
256 // Should be accepted or go to purgatory (not permanently rejected)
257 match result {
258 Ok(output) => {
259 let permanently_rejected = output.failed.values().any(|err| {
260 let err_str = err.to_string();
261 err_str.contains("not authorized") || err_str.contains("no announcement exists")
262 });
263 assert!(
264 !permanently_rejected,
265 "Event should not be permanently rejected when maintainer is authorized"
266 );
267 }
268 Err(e) => {
269 // Purgatory is acceptable
270 assert!(
271 e.to_string().contains("purgatory") ||
272 e.to_string().contains("waiting for git"),
273 "Error should be about purgatory, not authorization: {}",
274 e
275 );
276 }
277 }
278
279 relay.stop().await;
280}