Skip to main content

max / makenotwork

10.7 KB · 379 lines History Blame Raw
1 //! Inbound patch email webhook tests: auth, sender/project resolution, message-ID mapping.
2
3 use crate::harness::{BuildOptions, TestHarness};
4
5 const INBOUND_TOKEN: &str = "test-inbound-patch-token";
6
7 /// Helper: POST a Postmark inbound payload with optional auth token.
8 async fn post_inbound(h: &mut TestHarness, token: Option<&str>, body: &str) -> u16 {
9 let mut headers = vec![("Content-Type", "application/json")];
10 let auth;
11 if let Some(t) = token {
12 auth = format!("Bearer {t}");
13 headers.push(("Authorization", &auth));
14 }
15 let resp = h
16 .client
17 .request_with_headers("POST", "/postmark/inbound", Some(body), &headers)
18 .await;
19 resp.status.as_u16()
20 }
21
22 /// Build a harness with inbound webhook token configured.
23 async fn harness_with_inbound() -> TestHarness {
24 TestHarness::build(BuildOptions {
25 postmark_inbound_webhook_token: Some(INBOUND_TOKEN.to_string()),
26 ..Default::default()
27 })
28 .await
29 }
30
31 /// Build a minimal Postmark inbound JSON payload.
32 fn inbound_payload(
33 from_email: &str,
34 to: &str,
35 subject: &str,
36 text_body: &str,
37 message_id: &str,
38 extra_headers: &[(&str, &str)],
39 ) -> String {
40 let mut headers: Vec<serde_json::Value> = extra_headers
41 .iter()
42 .map(|(name, value)| serde_json::json!({"Name": name, "Value": value}))
43 .collect();
44
45 // Default to a passing SPF/DKIM verdict aligned with the From domain (normal
46 // production inbound) unless the caller supplied their own auth header, the
47 // server requires alignment before trusting the sender (Run 13 spoofing).
48 let has_auth = extra_headers.iter().any(|(n, _)| {
49 n.eq_ignore_ascii_case("Authentication-Results") || n.eq_ignore_ascii_case("Received-SPF")
50 });
51 if !has_auth {
52 let domain = from_email.rsplit('@').next().unwrap_or("example.com");
53 headers.push(serde_json::json!({
54 "Name": "Authentication-Results",
55 "Value": format!("mx.postmark.com; spf=pass smtp.mailfrom={from_email}; dkim=pass header.d={domain}")
56 }));
57 }
58
59 serde_json::json!({
60 "FromFull": {"Email": from_email, "Name": "Test Sender"},
61 "From": from_email,
62 "To": to,
63 "Subject": subject,
64 "TextBody": text_body,
65 "MessageID": message_id,
66 "Headers": headers
67 })
68 .to_string()
69 }
70
71 // ── Auth ──
72
73 #[tokio::test]
74 async fn inbound_missing_token_returns_401() {
75 let mut h = harness_with_inbound().await;
76 let body = inbound_payload(
77 "alice@test.com",
78 "my-proj@patches.makenot.work",
79 "[PATCH] Fix typo",
80 "diff --git",
81 "<abc@test>",
82 &[],
83 );
84 let status = post_inbound(&mut h, None, &body).await;
85 assert_eq!(status, 401);
86 }
87
88 #[tokio::test]
89 async fn inbound_invalid_token_returns_401() {
90 let mut h = harness_with_inbound().await;
91 let body = inbound_payload(
92 "alice@test.com",
93 "my-proj@patches.makenot.work",
94 "[PATCH] Fix typo",
95 "diff --git",
96 "<abc@test>",
97 &[],
98 );
99 let status = post_inbound(&mut h, Some("wrong-token"), &body).await;
100 assert_eq!(status, 401);
101 }
102
103 #[tokio::test]
104 async fn inbound_no_configured_token_returns_401() {
105 // Default harness has no inbound token configured
106 let mut h = TestHarness::new().await;
107 let body = inbound_payload(
108 "alice@test.com",
109 "my-proj@patches.makenot.work",
110 "[PATCH] Fix typo",
111 "diff --git",
112 "<abc@test>",
113 &[],
114 );
115 let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await;
116 assert_eq!(status, 401);
117 }
118
119 // ── Project / sender resolution ──
120
121 #[tokio::test]
122 async fn inbound_unknown_project_returns_200_no_side_effects() {
123 let mut h = harness_with_inbound().await;
124
125 // Create a user so sender lookup succeeds, but project doesn't exist
126 let user_id = h
127 .signup("patchuser", "patchuser@test.com", "password123")
128 .await;
129 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
130 .bind(user_id)
131 .execute(&h.db)
132 .await
133 .unwrap();
134
135 let body = inbound_payload(
136 "patchuser@test.com",
137 "nonexistent@patches.makenot.work",
138 "[PATCH] Fix typo",
139 "diff --git",
140 "<msg1@test>",
141 &[],
142 );
143 let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await;
144 assert_eq!(status, 200);
145
146 // No patch_message_ids rows should exist
147 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM patch_message_ids")
148 .fetch_one(&h.db)
149 .await
150 .unwrap();
151 assert_eq!(count, 0);
152 }
153
154 #[tokio::test]
155 async fn inbound_unknown_sender_returns_200_no_side_effects() {
156 let mut h = harness_with_inbound().await;
157
158 // Create a project but no user with matching email
159 let creator_id = h.create_creator("patchcreator").await;
160 h.grant_creator(creator_id).await;
161 h.client
162 .post_form("/api/projects", "slug=test-repo&title=Test+Repo")
163 .await;
164
165 // Publish the project
166 let project_id: String =
167 sqlx::query_scalar("SELECT id::text FROM projects WHERE slug = 'test-repo'")
168 .fetch_one(&h.db)
169 .await
170 .unwrap();
171 h.client
172 .put_json(
173 &format!("/api/projects/{project_id}"),
174 r#"{"is_public": true}"#,
175 )
176 .await;
177
178 let body = inbound_payload(
179 "stranger@example.com",
180 "test-repo@patches.makenot.work",
181 "[PATCH] Fix typo",
182 "diff --git",
183 "<msg2@test>",
184 &[],
185 );
186 let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await;
187 assert_eq!(status, 200);
188
189 // No patch_message_ids rows should exist
190 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM patch_message_ids")
191 .fetch_one(&h.db)
192 .await
193 .unwrap();
194 assert_eq!(count, 0);
195 }
196
197 #[tokio::test]
198 async fn inbound_unverified_sender_returns_200_no_side_effects() {
199 let mut h = harness_with_inbound().await;
200
201 // Create user but don't verify email
202 h.signup("unverified", "unverified@test.com", "password123")
203 .await;
204
205 let body = inbound_payload(
206 "unverified@test.com",
207 "some-proj@patches.makenot.work",
208 "[PATCH] Fix typo",
209 "diff --git",
210 "<msg3@test>",
211 &[],
212 );
213 let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await;
214 assert_eq!(status, 200);
215
216 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM patch_message_ids")
217 .fetch_one(&h.db)
218 .await
219 .unwrap();
220 assert_eq!(count, 0);
221 }
222
223 // ── Database layer: message-ID mapping ──
224
225 #[tokio::test]
226 async fn patch_message_id_insert_and_lookup() {
227 let h = harness_with_inbound().await;
228
229 // Create a project to reference
230 let project_id: uuid::Uuid = sqlx::query_scalar(
231 "INSERT INTO users (username, email, password_hash) VALUES ('dbuser', 'db@test.com', 'hash') RETURNING id",
232 )
233 .fetch_one(&h.db)
234 .await
235 .unwrap();
236
237 let proj_id: makenotwork::db::ProjectId = sqlx::query_scalar(
238 "INSERT INTO projects (user_id, slug, title, project_type) VALUES ($1, 'db-proj', 'DB Project', 'software') RETURNING id",
239 )
240 .bind(project_id)
241 .fetch_one(&h.db)
242 .await
243 .unwrap();
244
245 let thread_id = makenotwork::db::MtThreadId::new();
246
247 makenotwork::db::patches::insert_patch_message_id(
248 &h.db,
249 "<test-msg-1@example.com>",
250 proj_id,
251 thread_id,
252 )
253 .await
254 .unwrap();
255
256 // Lookup by single ID
257 let found =
258 makenotwork::db::patches::get_thread_id_by_message_id(&h.db, "<test-msg-1@example.com>")
259 .await
260 .unwrap();
261 assert_eq!(found, Some(thread_id));
262
263 // Lookup non-existent
264 let not_found =
265 makenotwork::db::patches::get_thread_id_by_message_id(&h.db, "<nonexistent@example.com>")
266 .await
267 .unwrap();
268 assert_eq!(not_found, None);
269 }
270
271 #[tokio::test]
272 async fn patch_message_id_lookup_any() {
273 let h = harness_with_inbound().await;
274
275 let user_id: uuid::Uuid = sqlx::query_scalar(
276 "INSERT INTO users (username, email, password_hash) VALUES ('anyuser', 'any@test.com', 'hash') RETURNING id",
277 )
278 .fetch_one(&h.db)
279 .await
280 .unwrap();
281
282 let proj_id: makenotwork::db::ProjectId = sqlx::query_scalar(
283 "INSERT INTO projects (user_id, slug, title, project_type) VALUES ($1, 'any-proj', 'Any Project', 'software') RETURNING id",
284 )
285 .bind(user_id)
286 .fetch_one(&h.db)
287 .await
288 .unwrap();
289
290 let thread_id = makenotwork::db::MtThreadId::new();
291
292 makenotwork::db::patches::insert_patch_message_id(
293 &h.db,
294 "<series-1@example.com>",
295 proj_id,
296 thread_id,
297 )
298 .await
299 .unwrap();
300
301 // Lookup with a mix of known and unknown IDs (simulates References header)
302 let found = makenotwork::db::patches::get_thread_id_by_any_message_id(
303 &h.db,
304 &[
305 "<unknown@example.com>",
306 "<series-1@example.com>",
307 "<also-unknown@example.com>",
308 ],
309 )
310 .await
311 .unwrap();
312 assert_eq!(found, Some(thread_id));
313
314 // Lookup with all unknown IDs
315 let not_found = makenotwork::db::patches::get_thread_id_by_any_message_id(
316 &h.db,
317 &["<a@example.com>", "<b@example.com>"],
318 )
319 .await
320 .unwrap();
321 assert_eq!(not_found, None);
322
323 // Lookup with empty list
324 let empty = makenotwork::db::patches::get_thread_id_by_any_message_id(&h.db, &[])
325 .await
326 .unwrap();
327 assert_eq!(empty, None);
328 }
329
330 #[tokio::test]
331 async fn patch_message_id_duplicate_is_idempotent() {
332 let h = harness_with_inbound().await;
333
334 let user_id: uuid::Uuid = sqlx::query_scalar(
335 "INSERT INTO users (username, email, password_hash) VALUES ('dupeuser', 'dupe@test.com', 'hash') RETURNING id",
336 )
337 .fetch_one(&h.db)
338 .await
339 .unwrap();
340
341 let proj_id: makenotwork::db::ProjectId = sqlx::query_scalar(
342 "INSERT INTO projects (user_id, slug, title, project_type) VALUES ($1, 'dupe-proj', 'Dupe Project', 'software') RETURNING id",
343 )
344 .bind(user_id)
345 .fetch_one(&h.db)
346 .await
347 .unwrap();
348
349 let thread_id = makenotwork::db::MtThreadId::new();
350
351 // Insert twice with same message_id
352 makenotwork::db::patches::insert_patch_message_id(
353 &h.db,
354 "<dupe@example.com>",
355 proj_id,
356 thread_id,
357 )
358 .await
359 .unwrap();
360
361 makenotwork::db::patches::insert_patch_message_id(
362 &h.db,
363 "<dupe@example.com>",
364 proj_id,
365 thread_id,
366 )
367 .await
368 .unwrap();
369
370 // Should have exactly one row
371 let count: i64 = sqlx::query_scalar(
372 "SELECT COUNT(*) FROM patch_message_ids WHERE message_id = '<dupe@example.com>'",
373 )
374 .fetch_one(&h.db)
375 .await
376 .unwrap();
377 assert_eq!(count, 1);
378 }
379