Skip to main content

max / makenotwork

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