Skip to main content

max / makenotwork

44.5 KB · 1290 lines History Blame Raw
1 //! Git issue tracker integration tests.
2 //!
3 //! Tests the email-first issue tracker: read-only web UI, inbound email
4 //! issue creation and replies, push_refs close/reopen, and that old
5 //! write routes return 404/405.
6
7 use crate::harness::{BuildOptions, TestHarness};
8 use wiremock::matchers::{method, path};
9 use wiremock::{Mock, MockServer, ResponseTemplate};
10
11 /// Compute the per-repo HMAC that the push endpoint expects.
12 fn push_token(owner: &str, repo: &str) -> String {
13 makenotwork::build_runner::repo_hmac("test-trigger-secret", owner, repo)
14 }
15
16 /// Create a temp bare repo at `{dir}/testowner/testrepo.git` with one commit on "main".
17 fn make_test_repo(dir: &std::path::Path) {
18 use crate::harness::gitfixture::{blob, commit, init_bare, tree};
19 use gix::objs::tree::EntryKind;
20
21 let repo = init_bare(dir, "testowner", "testrepo");
22 let readme = blob(&repo, b"# Test Repo\n");
23 let root = tree(&repo, &[("README.md", readme, EntryKind::Blob)]);
24 commit(&repo, "Initial commit", root, Vec::new());
25 }
26
27 /// Set up harness with git repos and owner user logged in.
28 async fn setup(tmp: &tempfile::TempDir) -> TestHarness {
29 let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
30 h.signup("testowner", "testowner@example.com", "password123")
31 .await;
32 // Visit repo to auto-register it
33 let resp = h.client.get("/git/testowner/testrepo").await;
34 assert_eq!(resp.status, 200, "Repo setup failed: {}", resp.text);
35 h
36 }
37
38 /// Set up harness with both git repos and an inbound webhook token.
39 async fn setup_with_inbound(tmp: &tempfile::TempDir) -> TestHarness {
40 let mut h = TestHarness::build(BuildOptions {
41 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
42 postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()),
43 build_trigger_token: Some("test-trigger-secret".to_string()),
44 ..Default::default()
45 })
46 .await;
47 let user_id = h
48 .signup("testowner", "testowner@example.com", "password123")
49 .await;
50 // Mark email as verified (required for inbound email processing)
51 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
52 .bind(user_id)
53 .execute(&h.db)
54 .await
55 .unwrap();
56 // Visit repo to auto-register it
57 let resp = h.client.get("/git/testowner/testrepo").await;
58 assert_eq!(resp.status, 200, "Repo setup failed: {}", resp.text);
59 h
60 }
61
62 /// Build a Postmark inbound payload JSON string. Includes an SPF/DKIM verdict
63 /// aligned with the From domain, normal production inbound, which the server
64 /// now requires before trusting the sender's identity (Run 13 sender-spoofing).
65 fn inbound_payload(to: &str, from_email: &str, subject: &str, body: &str) -> String {
66 let domain = from_email.rsplit('@').next().unwrap_or("example.com");
67 let auth = format!(
68 "mx.postmark.com; spf=pass smtp.mailfrom={from_email}; dkim=pass header.d={domain}"
69 );
70 inbound_payload_with_auth(to, from_email, subject, body, Some(&auth))
71 }
72
73 /// Like `inbound_payload` but with an explicit `Authentication-Results` value
74 /// (`None` = no auth header at all), for exercising the sender-auth gate.
75 fn inbound_payload_with_auth(
76 to: &str,
77 from_email: &str,
78 subject: &str,
79 body: &str,
80 auth_results: Option<&str>,
81 ) -> String {
82 let headers = match auth_results {
83 Some(v) => serde_json::json!([{ "Name": "Authentication-Results", "Value": v }]),
84 None => serde_json::json!([]),
85 };
86 serde_json::json!({
87 "FromFull": { "Email": from_email, "Name": "" },
88 "To": to,
89 "Subject": subject,
90 "TextBody": body,
91 "MessageID": format!("test-msg-{}", uuid::Uuid::new_v4()),
92 "Headers": headers
93 })
94 .to_string()
95 }
96
97 /// Add a commit with the given message to the bare repo, returning (before_oid, after_oid).
98 fn add_commit_to_repo(dir: &std::path::Path, message: &str) -> (String, String) {
99 let bare_path = dir.join("testowner").join("testrepo.git");
100 let repo = gix::open(&bare_path).unwrap();
101
102 let parent = repo.head_commit().unwrap();
103 let before = parent.id().to_string();
104 let tree = parent.tree().unwrap().id;
105
106 // Same tree as the parent: these tests only care about the message.
107 let after =
108 crate::harness::gitfixture::commit(&repo, message, tree, vec![parent.id().detach()]);
109
110 (before, after.to_string())
111 }
112
113 // ══════════════════════════════════════════════════════════════════════
114 // Read-only web UI tests
115 // ══════════════════════════════════════════════════════════════════════
116
117 #[tokio::test]
118 async fn issue_list_loads() {
119 let tmp = tempfile::TempDir::new().unwrap();
120 make_test_repo(tmp.path());
121 let mut h = setup(&tmp).await;
122
123 let resp = h.client.get("/git/testowner/testrepo/issues").await;
124 assert_eq!(resp.status, 200, "{}", resp.text);
125 assert!(resp.text.contains("Issues"));
126 }
127
128 #[tokio::test]
129 async fn issue_list_no_create_button() {
130 let tmp = tempfile::TempDir::new().unwrap();
131 make_test_repo(tmp.path());
132 let mut h = setup(&tmp).await;
133
134 let resp = h.client.get("/git/testowner/testrepo/issues").await;
135 assert_eq!(resp.status, 200, "{}", resp.text);
136 assert!(
137 !resp.text.contains("New issue"),
138 "Should have no 'New issue' button"
139 );
140 assert!(
141 !resp.text.contains("/issues/new"),
142 "Should have no link to new issue form"
143 );
144 }
145
146 #[tokio::test]
147 async fn issue_list_shows_email_address() {
148 let tmp = tempfile::TempDir::new().unwrap();
149 make_test_repo(tmp.path());
150 let mut h = setup(&tmp).await;
151
152 let resp = h.client.get("/git/testowner/testrepo/issues").await;
153 assert_eq!(resp.status, 200, "{}", resp.text);
154 assert!(
155 resp.text.contains("testowner+testrepo@issues.makenot.work"),
156 "Should show email address for opening issues"
157 );
158 }
159
160 #[tokio::test]
161 async fn issue_detail_loads() {
162 let tmp = tempfile::TempDir::new().unwrap();
163 make_test_repo(tmp.path());
164 let mut h = setup_with_inbound(&tmp).await;
165
166 // Create issue via DB directly
167 let repo_id: uuid::Uuid =
168 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
169 .fetch_one(&h.db)
170 .await
171 .unwrap();
172
173 let user_id: uuid::Uuid =
174 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
175 .fetch_one(&h.db)
176 .await
177 .unwrap();
178
179 sqlx::query(
180 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test issue', 'Body text', '<p>Body text</p>')"
181 )
182 .bind(repo_id)
183 .bind(user_id)
184 .execute(&h.db)
185 .await
186 .unwrap();
187
188 let resp = h.client.get("/git/testowner/testrepo/issues/1").await;
189 assert_eq!(resp.status, 200, "{}", resp.text);
190 assert!(resp.text.contains("Test issue"));
191 assert!(resp.text.contains("Body text"));
192 }
193
194 #[tokio::test]
195 async fn issue_detail_no_write_ui() {
196 let tmp = tempfile::TempDir::new().unwrap();
197 make_test_repo(tmp.path());
198 let mut h = setup_with_inbound(&tmp).await;
199
200 // Create issue via DB
201 let repo_id: uuid::Uuid =
202 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
203 .fetch_one(&h.db)
204 .await
205 .unwrap();
206
207 let user_id: uuid::Uuid =
208 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
209 .fetch_one(&h.db)
210 .await
211 .unwrap();
212
213 sqlx::query(
214 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '')"
215 )
216 .bind(repo_id)
217 .bind(user_id)
218 .execute(&h.db)
219 .await
220 .unwrap();
221
222 let resp = h.client.get("/git/testowner/testrepo/issues/1").await;
223 assert_eq!(resp.status, 200, "{}", resp.text);
224 // No comment form, no edit button, no close/reopen buttons
225 assert!(
226 !resp.text.contains("Add a comment"),
227 "Should have no comment form"
228 );
229 assert!(!resp.text.contains("/edit"), "Should have no edit link");
230 assert!(
231 !resp.text.contains("Close issue"),
232 "Should have no close button"
233 );
234 assert!(
235 !resp.text.contains("Reopen issue"),
236 "Should have no reopen button"
237 );
238 }
239
240 #[tokio::test]
241 async fn issue_detail_shows_email_address() {
242 let tmp = tempfile::TempDir::new().unwrap();
243 make_test_repo(tmp.path());
244 let mut h = setup_with_inbound(&tmp).await;
245
246 let repo_id: uuid::Uuid =
247 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
248 .fetch_one(&h.db)
249 .await
250 .unwrap();
251 let user_id: uuid::Uuid =
252 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
253 .fetch_one(&h.db)
254 .await
255 .unwrap();
256
257 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '')")
258 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
259
260 let resp = h.client.get("/git/testowner/testrepo/issues/1").await;
261 assert_eq!(resp.status, 200, "{}", resp.text);
262 assert!(
263 resp.text.contains("testowner+testrepo@issues.makenot.work"),
264 "Should show email address instructions"
265 );
266 }
267
268 #[tokio::test]
269 async fn nonexistent_issue_returns_404() {
270 let tmp = tempfile::TempDir::new().unwrap();
271 make_test_repo(tmp.path());
272 let mut h = setup(&tmp).await;
273
274 let resp = h.client.get("/git/testowner/testrepo/issues/999").await;
275 assert_eq!(resp.status, 404);
276 }
277
278 #[tokio::test]
279 async fn search_issues() {
280 let tmp = tempfile::TempDir::new().unwrap();
281 make_test_repo(tmp.path());
282 let mut h = setup_with_inbound(&tmp).await;
283
284 let repo_id: uuid::Uuid =
285 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
286 .fetch_one(&h.db)
287 .await
288 .unwrap();
289 let user_id: uuid::Uuid =
290 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
291 .fetch_one(&h.db)
292 .await
293 .unwrap();
294
295 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Fix the login bug', '', '')")
296 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
297 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 2, $2, 'Add dark mode', '', '')")
298 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
299
300 let resp = h
301 .client
302 .get("/git/testowner/testrepo/issues?search=login")
303 .await;
304 assert_eq!(resp.status, 200, "{}", resp.text);
305 assert!(
306 resp.text.contains("login bug"),
307 "Should find matching issue"
308 );
309 assert!(
310 !resp.text.contains("dark mode"),
311 "Should not show non-matching issue"
312 );
313 }
314
315 // ══════════════════════════════════════════════════════════════════════
316 // Inbound email tests
317 // ══════════════════════════════════════════════════════════════════════
318
319 #[tokio::test]
320 async fn inbound_new_issue_creates_issue() {
321 let tmp = tempfile::TempDir::new().unwrap();
322 make_test_repo(tmp.path());
323 let mut h = setup_with_inbound(&tmp).await;
324
325 h.client.set_bearer_token("test-inbound-secret");
326 let payload = inbound_payload(
327 "testowner+testrepo@issues.makenot.work",
328 "testowner@example.com",
329 "Bug via email",
330 "This is the body of the issue.",
331 );
332 let resp = h
333 .client
334 .post_json("/postmark/inbound-issues", &payload)
335 .await;
336 assert_eq!(resp.status, 200, "Inbound should succeed: {}", resp.text);
337
338 // Verify issue appears in DB
339 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
340 .fetch_one(&h.db)
341 .await
342 .unwrap();
343 assert_eq!(count, 1, "Should have created one issue");
344
345 // Verify on list page
346 h.client.clear_bearer_token();
347 let resp = h.client.get("/git/testowner/testrepo/issues").await;
348 assert!(
349 resp.text.contains("Bug via email"),
350 "Issue should appear in list"
351 );
352 }
353
354 #[tokio::test]
355 async fn inbound_spoofed_sender_is_rejected() {
356 // A forged `From:` matching a verified user, but the SPF/DKIM verdict is
357 // aligned to the attacker's domain, not the From domain, must NOT create an
358 // issue as that user (Run 13 sender-spoofing). Returns 200 (no oracle) but
359 // writes nothing.
360 let tmp = tempfile::TempDir::new().unwrap();
361 make_test_repo(tmp.path());
362 let mut h = setup_with_inbound(&tmp).await;
363
364 h.client.set_bearer_token("test-inbound-secret");
365 let spoofed = inbound_payload_with_auth(
366 "testowner+testrepo@issues.makenot.work",
367 "testowner@example.com",
368 "Spoofed issue",
369 "I am pretending to be the owner.",
370 Some(
371 "mx.postmark.com; spf=pass smtp.mailfrom=mallory@evil.test; dkim=pass header.d=evil.test",
372 ),
373 );
374 let resp = h
375 .client
376 .post_json("/postmark/inbound-issues", &spoofed)
377 .await;
378 assert_eq!(
379 resp.status, 200,
380 "spoofed inbound should ack without an oracle: {}",
381 resp.text
382 );
383
384 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
385 .fetch_one(&h.db)
386 .await
387 .unwrap();
388 assert_eq!(count, 0, "a spoofed sender must not create an issue");
389 }
390
391 #[tokio::test]
392 async fn inbound_duplicate_delivery_creates_one_issue() {
393 // Postmark redelivers on our 5xx-transient responses; a retry carrying the
394 // same MessageID must not create a duplicate issue (Run 13 idempotency).
395 let tmp = tempfile::TempDir::new().unwrap();
396 make_test_repo(tmp.path());
397 let mut h = setup_with_inbound(&tmp).await;
398
399 h.client.set_bearer_token("test-inbound-secret");
400 // Reusing the same payload string reuses the same MessageID.
401 let payload = inbound_payload(
402 "testowner+testrepo@issues.makenot.work",
403 "testowner@example.com",
404 "Duplicate delivery",
405 "This should only create one issue.",
406 );
407 let r1 = h
408 .client
409 .post_json("/postmark/inbound-issues", &payload)
410 .await;
411 assert_eq!(r1.status, 200, "first delivery: {}", r1.text);
412 let r2 = h
413 .client
414 .post_json("/postmark/inbound-issues", &payload)
415 .await;
416 assert_eq!(r2.status, 200, "redelivery ack: {}", r2.text);
417
418 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
419 .fetch_one(&h.db)
420 .await
421 .unwrap();
422 assert_eq!(
423 count, 1,
424 "a redelivered MessageID must not create a duplicate issue"
425 );
426 }
427
428 #[tokio::test]
429 async fn inbound_missing_auth_is_rejected() {
430 // No SPF/DKIM verdict at all (enforce mode), the sender is unauthenticated,
431 // so no issue is attributed to the claimed From user.
432 let tmp = tempfile::TempDir::new().unwrap();
433 make_test_repo(tmp.path());
434 let mut h = setup_with_inbound(&tmp).await;
435
436 h.client.set_bearer_token("test-inbound-secret");
437 let no_auth = inbound_payload_with_auth(
438 "testowner+testrepo@issues.makenot.work",
439 "testowner@example.com",
440 "Unauthenticated issue",
441 "No auth headers present.",
442 None,
443 );
444 let resp = h
445 .client
446 .post_json("/postmark/inbound-issues", &no_auth)
447 .await;
448 assert_eq!(resp.status, 200);
449
450 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
451 .fetch_one(&h.db)
452 .await
453 .unwrap();
454 assert_eq!(
455 count, 0,
456 "an unauthenticated sender must not create an issue"
457 );
458 }
459
460 #[tokio::test]
461 async fn inbound_new_issue_requires_verified_sender() {
462 let tmp = tempfile::TempDir::new().unwrap();
463 make_test_repo(tmp.path());
464 let mut h = setup_with_inbound(&tmp).await;
465
466 // Unknown sender
467 h.client.set_bearer_token("test-inbound-secret");
468 let payload = inbound_payload(
469 "testowner+testrepo@issues.makenot.work",
470 "nobody@example.com",
471 "Should fail",
472 "Body",
473 );
474 let resp = h
475 .client
476 .post_json("/postmark/inbound-issues", &payload)
477 .await;
478 assert_eq!(
479 resp.status, 200,
480 "Should still return 200 (silently ignore)"
481 );
482
483 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
484 .fetch_one(&h.db)
485 .await
486 .unwrap();
487 assert_eq!(count, 0, "No issue should be created for unknown sender");
488 }
489
490 #[tokio::test]
491 async fn inbound_new_issue_blocked_on_private_repo_for_non_collaborator() {
492 // M-Sec3: inbound email issue creation must honor repo visibility. A verified
493 // user who is neither owner nor collaborator cannot open issues on a PRIVATE
494 // repo (it would be spam plus an existence oracle). The endpoint still returns
495 // 200 (no oracle) but creates nothing; the owner is unaffected.
496 let tmp = tempfile::TempDir::new().unwrap();
497 make_test_repo(tmp.path());
498 let mut h = setup_with_inbound(&tmp).await;
499
500 // Make the repo private.
501 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
502 .execute(&h.db)
503 .await
504 .unwrap();
505
506 // A verified outsider: not the owner, not a collaborator.
507 h.signup("outsider", "outsider@example.com", "password123")
508 .await;
509 sqlx::query("UPDATE users SET email_verified = true WHERE email = 'outsider@example.com'")
510 .execute(&h.db)
511 .await
512 .unwrap();
513
514 h.client.set_bearer_token("test-inbound-secret");
515 let payload = inbound_payload(
516 "testowner+testrepo@issues.makenot.work",
517 "outsider@example.com",
518 "Sneaky issue",
519 "Body",
520 );
521 let resp = h
522 .client
523 .post_json("/postmark/inbound-issues", &payload)
524 .await;
525 assert_eq!(
526 resp.status, 200,
527 "inbound returns 200 (no existence oracle): {}",
528 resp.text
529 );
530 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
531 .fetch_one(&h.db)
532 .await
533 .unwrap();
534 assert_eq!(
535 count, 0,
536 "non-collaborator must not open an issue on a private repo"
537 );
538
539 // Control: the owner CAN still open an issue on their own private repo.
540 let payload = inbound_payload(
541 "testowner+testrepo@issues.makenot.work",
542 "testowner@example.com",
543 "Owner issue",
544 "Body",
545 );
546 let resp = h
547 .client
548 .post_json("/postmark/inbound-issues", &payload)
549 .await;
550 assert_eq!(
551 resp.status, 200,
552 "owner inbound should succeed: {}",
553 resp.text
554 );
555 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
556 .fetch_one(&h.db)
557 .await
558 .unwrap();
559 assert_eq!(
560 count, 1,
561 "owner can still open an issue on their own private repo"
562 );
563 }
564
565 #[tokio::test]
566 async fn inbound_reply_creates_comment() {
567 let tmp = tempfile::TempDir::new().unwrap();
568 make_test_repo(tmp.path());
569 let mut h = setup_with_inbound(&tmp).await;
570
571 // Create issue via DB
572 let repo_id: uuid::Uuid =
573 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
574 .fetch_one(&h.db)
575 .await
576 .unwrap();
577 let user_id: uuid::Uuid =
578 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
579 .fetch_one(&h.db)
580 .await
581 .unwrap();
582 let issue_id: uuid::Uuid = sqlx::query_scalar(
583 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id"
584 )
585 .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap();
586
587 // Generate a valid reply address
588 let reply_addr = makenotwork::email::generate_issue_reply_address(
589 makenotwork::db::IssueId::from(issue_id),
590 makenotwork::db::UserId::from(user_id),
591 "test-signing-secret-for-integration-tests",
592 );
593
594 h.client.set_bearer_token("test-inbound-secret");
595 let payload = inbound_payload(
596 &reply_addr,
597 "testowner@example.com",
598 "Re: Test",
599 "This is my reply.\n\n> Previous message\n> more quoted text",
600 );
601 let resp = h
602 .client
603 .post_json("/postmark/inbound-issues", &payload)
604 .await;
605 assert_eq!(resp.status, 200);
606
607 // Check comment was created (with quoted text stripped)
608 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
609 .bind(issue_id)
610 .fetch_one(&h.db)
611 .await
612 .unwrap();
613 assert_eq!(count, 1, "Should have created one comment");
614
615 let body: String =
616 sqlx::query_scalar("SELECT body_markdown FROM issue_comments WHERE issue_id = $1")
617 .bind(issue_id)
618 .fetch_one(&h.db)
619 .await
620 .unwrap();
621 assert!(
622 body.contains("This is my reply"),
623 "Comment should have reply text"
624 );
625 assert!(
626 !body.contains("Previous message"),
627 "Quoted text should be stripped"
628 );
629 }
630
631 #[tokio::test]
632 async fn inbound_reply_invalid_token_rejected() {
633 let tmp = tempfile::TempDir::new().unwrap();
634 make_test_repo(tmp.path());
635 let mut h = setup_with_inbound(&tmp).await;
636
637 // Create issue via DB
638 let repo_id: uuid::Uuid =
639 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
640 .fetch_one(&h.db)
641 .await
642 .unwrap();
643 let user_id: uuid::Uuid =
644 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
645 .fetch_one(&h.db)
646 .await
647 .unwrap();
648 let issue_id: uuid::Uuid = sqlx::query_scalar(
649 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id"
650 )
651 .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap();
652
653 // Forge a reply address with wrong HMAC
654 let bad_addr = format!("issue+{issue_id}.{user_id}.deadbeefdeadbeef@reply.makenot.work");
655
656 h.client.set_bearer_token("test-inbound-secret");
657 let payload = inbound_payload(
658 &bad_addr,
659 "testowner@example.com",
660 "Re: Test",
661 "Hack attempt",
662 );
663 let resp = h
664 .client
665 .post_json("/postmark/inbound-issues", &payload)
666 .await;
667 assert_eq!(resp.status, 200, "Should return 200 but silently ignore");
668
669 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
670 .bind(issue_id)
671 .fetch_one(&h.db)
672 .await
673 .unwrap();
674 assert_eq!(count, 0, "No comment should be created for invalid token");
675 }
676
677 #[tokio::test]
678 async fn inbound_reply_wrong_sender_rejected() {
679 let tmp = tempfile::TempDir::new().unwrap();
680 make_test_repo(tmp.path());
681 let mut h = setup_with_inbound(&tmp).await;
682
683 // Create a second verified user
684 h.client.post_form("/logout", "").await;
685 let other_id = h
686 .signup("otheruser", "other@example.com", "password123")
687 .await;
688 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
689 .bind(other_id)
690 .execute(&h.db)
691 .await
692 .unwrap();
693
694 let repo_id: uuid::Uuid =
695 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
696 .fetch_one(&h.db)
697 .await
698 .unwrap();
699 let owner_user_id: uuid::Uuid =
700 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
701 .fetch_one(&h.db)
702 .await
703 .unwrap();
704 let issue_id: uuid::Uuid = sqlx::query_scalar(
705 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id"
706 )
707 .bind(repo_id).bind(owner_user_id).fetch_one(&h.db).await.unwrap();
708
709 // Generate reply address for owner, but send from other user
710 let reply_addr = makenotwork::email::generate_issue_reply_address(
711 makenotwork::db::IssueId::from(issue_id),
712 makenotwork::db::UserId::from(owner_user_id),
713 "test-signing-secret-for-integration-tests",
714 );
715
716 h.client.set_bearer_token("test-inbound-secret");
717 let payload = inbound_payload(&reply_addr, "other@example.com", "Re: Test", "Wrong sender");
718 let resp = h
719 .client
720 .post_json("/postmark/inbound-issues", &payload)
721 .await;
722 assert_eq!(resp.status, 200);
723
724 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
725 .bind(issue_id)
726 .fetch_one(&h.db)
727 .await
728 .unwrap();
729 assert_eq!(count, 0, "No comment: sender doesn't match token user_id");
730 }
731
732 #[tokio::test]
733 async fn inbound_requires_auth_token() {
734 let tmp = tempfile::TempDir::new().unwrap();
735 make_test_repo(tmp.path());
736 let mut h = setup_with_inbound(&tmp).await;
737
738 // No bearer token
739 let payload = inbound_payload(
740 "testowner+testrepo@issues.makenot.work",
741 "testowner@example.com",
742 "No auth",
743 "Body",
744 );
745 let resp = h
746 .client
747 .post_json("/postmark/inbound-issues", &payload)
748 .await;
749 assert_eq!(resp.status, 401, "Should reject unauthenticated request");
750 }
751
752 // ══════════════════════════════════════════════════════════════════════
753 // Old write routes return 404/405
754 // ══════════════════════════════════════════════════════════════════════
755
756 #[tokio::test]
757 async fn old_write_routes_return_404() {
758 let tmp = tempfile::TempDir::new().unwrap();
759 make_test_repo(tmp.path());
760 let mut h = setup(&tmp).await;
761
762 // These routes have been removed
763 let removed = [
764 ("GET", "/git/testowner/testrepo/issues/new"),
765 ("POST", "/git/testowner/testrepo/issues/1/close"),
766 ("POST", "/git/testowner/testrepo/issues/1/reopen"),
767 ("POST", "/git/testowner/testrepo/issues/1/comment"),
768 (
769 "POST",
770 "/git/testowner/testrepo/issues/1/comment/00000000-0000-0000-0000-000000000000/delete",
771 ),
772 ("GET", "/git/testowner/testrepo/issues/1/edit"),
773 ("POST", "/git/testowner/testrepo/issues/1/edit"),
774 ("GET", "/git/testowner/testrepo/issues/labels"),
775 ("POST", "/git/testowner/testrepo/issues/labels"),
776 ];
777
778 for (method, path) in removed {
779 let resp = match method {
780 "GET" => h.client.get(path).await,
781 "POST" => h.client.post_form(path, "").await,
782 _ => unreachable!(),
783 };
784 // 404 = route removed, 405 = method not allowed, 400 = path param parse error
785 // (e.g. "new" can't parse as i32 for the {number} param)
786 assert!(
787 resp.status == 400 || resp.status == 404 || resp.status == 405,
788 "{} {} should be 400/404/405 but got {}",
789 method,
790 path,
791 resp.status
792 );
793 }
794 }
795
796 // ══════════════════════════════════════════════════════════════════════
797 // Push refs
798 // ══════════════════════════════════════════════════════════════════════
799
800 /// Set up harness with both git repos and a build trigger token.
801 async fn setup_with_push(tmp: &tempfile::TempDir) -> TestHarness {
802 let mut h = TestHarness::build(BuildOptions {
803 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
804 build_trigger_token: Some("test-trigger-secret".to_string()),
805 ..Default::default()
806 })
807 .await;
808 h.signup("testowner", "testowner@example.com", "password123")
809 .await;
810 let resp = h.client.get("/git/testowner/testrepo").await;
811 assert_eq!(resp.status, 200, "Repo setup failed: {}", resp.text);
812 h
813 }
814
815 #[tokio::test]
816 async fn process_push_closes_issue() {
817 let tmp = tempfile::TempDir::new().unwrap();
818 make_test_repo(tmp.path());
819 let mut h = setup_with_push(&tmp).await;
820
821 // Create an issue via DB
822 let repo_id: uuid::Uuid =
823 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
824 .fetch_one(&h.db)
825 .await
826 .unwrap();
827 let user_id: uuid::Uuid =
828 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
829 .fetch_one(&h.db)
830 .await
831 .unwrap();
832 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html, status) VALUES ($1, 1, $2, 'Bug to fix', 'Needs fixing', '', 'open')")
833 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
834
835 let (before, after) = add_commit_to_repo(tmp.path(), "Fix the bug\n\nFixes #1");
836
837 h.client
838 .set_bearer_token(&push_token("testowner", "testrepo"));
839 let body = serde_json::json!({
840 "repo_owner": "testowner",
841 "repo_name": "testrepo",
842 "ref_name": "main",
843 "before": before,
844 "after": after,
845 });
846 let resp = h
847 .client
848 .post_json("/api/internal/issues/process-push", &body.to_string())
849 .await;
850 assert_eq!(
851 resp.status, 200,
852 "process-push should succeed: {}",
853 resp.text
854 );
855
856 let json: serde_json::Value = resp.json();
857 assert_eq!(json["processed"], 1);
858
859 // Verify issue is now closed
860 let status: String =
861 sqlx::query_scalar("SELECT status FROM issues WHERE number = 1 AND repo_id = $1")
862 .bind(repo_id)
863 .fetch_one(&h.db)
864 .await
865 .unwrap();
866 assert_eq!(status, "closed", "Issue should be closed");
867 }
868
869 #[tokio::test]
870 async fn push_refs_reopens_issue() {
871 let tmp = tempfile::TempDir::new().unwrap();
872 make_test_repo(tmp.path());
873 let mut h = setup_with_push(&tmp).await;
874
875 let repo_id: uuid::Uuid =
876 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
877 .fetch_one(&h.db)
878 .await
879 .unwrap();
880 let user_id: uuid::Uuid =
881 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
882 .fetch_one(&h.db)
883 .await
884 .unwrap();
885
886 // Create a closed issue
887 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html, status) VALUES ($1, 1, $2, 'Closed bug', '', '', 'closed')")
888 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
889
890 let (before, after) = add_commit_to_repo(tmp.path(), "Not actually fixed\n\nReopens #1");
891
892 h.client
893 .set_bearer_token(&push_token("testowner", "testrepo"));
894 let body = serde_json::json!({
895 "repo_owner": "testowner",
896 "repo_name": "testrepo",
897 "ref_name": "main",
898 "before": before,
899 "after": after,
900 });
901 let resp = h
902 .client
903 .post_json("/api/internal/issues/process-push", &body.to_string())
904 .await;
905 assert_eq!(resp.status, 200);
906
907 let json: serde_json::Value = resp.json();
908 assert_eq!(json["processed"], 1);
909
910 // Verify issue is now open
911 let status: String =
912 sqlx::query_scalar("SELECT status FROM issues WHERE number = 1 AND repo_id = $1")
913 .bind(repo_id)
914 .fetch_one(&h.db)
915 .await
916 .unwrap();
917 assert_eq!(status, "open", "Issue should be reopened");
918 }
919
920 #[tokio::test]
921 async fn process_push_references_issue() {
922 let tmp = tempfile::TempDir::new().unwrap();
923 make_test_repo(tmp.path());
924 let mut h = setup_with_push(&tmp).await;
925
926 let repo_id: uuid::Uuid =
927 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
928 .fetch_one(&h.db)
929 .await
930 .unwrap();
931 let user_id: uuid::Uuid =
932 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
933 .fetch_one(&h.db)
934 .await
935 .unwrap();
936 let issue_id: uuid::Uuid = sqlx::query_scalar(
937 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Tracked', '', '') RETURNING id"
938 )
939 .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap();
940
941 let (before, after) = add_commit_to_repo(tmp.path(), "Related work\n\nRefs #1");
942
943 h.client
944 .set_bearer_token(&push_token("testowner", "testrepo"));
945 let body = serde_json::json!({
946 "repo_owner": "testowner",
947 "repo_name": "testrepo",
948 "ref_name": "main",
949 "before": before,
950 "after": after,
951 });
952 let resp = h
953 .client
954 .post_json("/api/internal/issues/process-push", &body.to_string())
955 .await;
956 assert_eq!(resp.status, 200);
957
958 // Issue should still be open
959 let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE id = $1")
960 .bind(issue_id)
961 .fetch_one(&h.db)
962 .await
963 .unwrap();
964 assert_eq!(status, "open", "Issue should remain open on reference");
965
966 // Should have a comment
967 let comment_count: i64 =
968 sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
969 .bind(issue_id)
970 .fetch_one(&h.db)
971 .await
972 .unwrap();
973 assert_eq!(comment_count, 1, "Should have a reference comment");
974 }
975
976 #[tokio::test]
977 async fn process_push_requires_auth() {
978 let tmp = tempfile::TempDir::new().unwrap();
979 make_test_repo(tmp.path());
980 let mut h = setup_with_push(&tmp).await;
981
982 let body = serde_json::json!({
983 "repo_owner": "testowner",
984 "repo_name": "testrepo",
985 "ref_name": "main",
986 "before": "0000000000000000000000000000000000000000",
987 "after": "0000000000000000000000000000000000000001",
988 });
989 let resp = h
990 .client
991 .post_json("/api/internal/issues/process-push", &body.to_string())
992 .await;
993 assert_eq!(resp.status, 403, "Should reject unauthenticated request");
994 }
995
996 // ══════════════════════════════════════════════════════════════════════
997 // Repo settings (unchanged, still web-based)
998 // ══════════════════════════════════════════════════════════════════════
999
1000 #[tokio::test]
1001 async fn repo_settings_page_loads_for_owner() {
1002 let tmp = tempfile::TempDir::new().unwrap();
1003 make_test_repo(tmp.path());
1004 let mut h = setup(&tmp).await;
1005
1006 let resp = h.client.get("/git/testowner/testrepo/settings").await;
1007 assert_eq!(
1008 resp.status, 200,
1009 "Settings should load for owner: {}",
1010 resp.status
1011 );
1012 assert!(resp.text.contains("Repository Settings"));
1013 }
1014
1015 #[tokio::test]
1016 async fn repo_settings_denied_for_non_owner() {
1017 let tmp = tempfile::TempDir::new().unwrap();
1018 make_test_repo(tmp.path());
1019 let mut h = setup(&tmp).await;
1020
1021 // Publish it first. This test is about authorization -- the repo exists and
1022 // is visible, and you still may not open its settings -- so it needs a repo
1023 // the other user can see. Left private (the default since migration 182)
1024 // the answer is 404, which is the right answer to a different question.
1025 sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'")
1026 .execute(&h.db)
1027 .await
1028 .unwrap();
1029
1030 h.client.post_form("/logout", "").await;
1031 h.signup("otheruser", "other@example.com", "password123")
1032 .await;
1033 h.login("otheruser", "password123").await;
1034
1035 let resp = h.client.get("/git/testowner/testrepo/settings").await;
1036 assert_eq!(resp.status, 403, "Non-owner should be forbidden");
1037 }
1038
1039 #[tokio::test]
1040 async fn notify_issues_preference_roundtrip() {
1041 let mut h = TestHarness::new().await;
1042 let user = h
1043 .signup("prefuser", "prefuser@example.com", "password123")
1044 .await;
1045
1046 // The preference lives in subscriptions since migration 189; the column it
1047 // used to be is gone.
1048 use makenotwork::db::{ListKind, lists::may_notify};
1049
1050 assert!(
1051 may_notify(&h.db, user, ListKind::Issues).await.unwrap(),
1052 "issue mail should default on"
1053 );
1054
1055 let resp = h.client.put_form("/api/users/me/preferences", "").await;
1056 assert_eq!(resp.status, 200, "{}", resp.text);
1057 assert!(
1058 !may_notify(&h.db, user, ListKind::Issues).await.unwrap(),
1059 "saving with no checkboxes should turn it off"
1060 );
1061
1062 let resp = h
1063 .client
1064 .put_form("/api/users/me/preferences", "notify_issues=on")
1065 .await;
1066 assert_eq!(resp.status, 200, "{}", resp.text);
1067 assert!(
1068 may_notify(&h.db, user, ListKind::Issues).await.unwrap(),
1069 "it should be on again"
1070 );
1071 }
1072
1073 #[tokio::test]
1074 async fn repo_page_shows_issues_nav_link() {
1075 let tmp = tempfile::TempDir::new().unwrap();
1076 make_test_repo(tmp.path());
1077 let mut h = setup(&tmp).await;
1078
1079 let resp = h.client.get("/git/testowner/testrepo").await;
1080 assert_eq!(resp.status, 200, "{}", resp.text);
1081 assert!(
1082 resp.text.contains("/issues"),
1083 "Repo page should have issues link"
1084 );
1085 }
1086
1087 // ══════════════════════════════════════════════════════════════════════
1088 // Multithreaded bridge, issues mirror into a forum thread
1089 // ══════════════════════════════════════════════════════════════════════
1090
1091 async fn setup_with_inbound_and_mt(tmp: &tempfile::TempDir, mt_url: String) -> TestHarness {
1092 let mut h = TestHarness::build(BuildOptions {
1093 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
1094 postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()),
1095 build_trigger_token: Some("test-trigger-secret".to_string()),
1096 mt_base_url: Some(mt_url),
1097 internal_shared_secret: Some("test-mt-secret".to_string()),
1098 ..Default::default()
1099 })
1100 .await;
1101 let user_id = h
1102 .signup("testowner", "testowner@example.com", "password123")
1103 .await;
1104 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
1105 .bind(user_id)
1106 .execute(&h.db)
1107 .await
1108 .unwrap();
1109 let resp = h.client.get("/git/testowner/testrepo").await;
1110 assert_eq!(resp.status, 200, "Repo setup failed: {}", resp.text);
1111 h
1112 }
1113
1114 /// Link the test repo to a project so the bridge has a community slug.
1115 async fn attach_repo_to_project(h: &TestHarness, project_slug: &str) -> uuid::Uuid {
1116 let user_id: uuid::Uuid =
1117 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
1118 .fetch_one(&h.db)
1119 .await
1120 .unwrap();
1121 let project_id: uuid::Uuid = sqlx::query_scalar(
1122 "INSERT INTO projects (user_id, title, slug)
1123 VALUES ($1, 'Test Project', $2) RETURNING id",
1124 )
1125 .bind(user_id)
1126 .bind(project_slug)
1127 .fetch_one(&h.db)
1128 .await
1129 .unwrap();
1130 sqlx::query("UPDATE git_repos SET project_id = $1 WHERE name = 'testrepo'")
1131 .bind(project_id)
1132 .execute(&h.db)
1133 .await
1134 .unwrap();
1135 project_id
1136 }
1137
1138 #[tokio::test]
1139 async fn inbound_new_issue_bridges_to_mt() {
1140 let tmp = tempfile::TempDir::new().unwrap();
1141 make_test_repo(tmp.path());
1142 let mock = MockServer::start().await;
1143 let mt_thread_id = uuid::Uuid::new_v4();
1144 Mock::given(method("POST"))
1145 .and(path("/internal/threads"))
1146 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1147 "thread_id": mt_thread_id,
1148 "post_id": uuid::Uuid::new_v4(),
1149 "created": true,
1150 })))
1151 .expect(1)
1152 .mount(&mock)
1153 .await;
1154
1155 let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
1156 attach_repo_to_project(&h, "test-project").await;
1157
1158 h.client.set_bearer_token("test-inbound-secret");
1159 let payload = inbound_payload(
1160 "testowner+testrepo@issues.makenot.work",
1161 "testowner@example.com",
1162 "Bridge me",
1163 "Body text",
1164 );
1165 let resp = h
1166 .client
1167 .post_json("/postmark/inbound-issues", &payload)
1168 .await;
1169 assert_eq!(resp.status, 200, "inbound: {}", resp.text);
1170
1171 let stored: Option<uuid::Uuid> = sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1")
1172 .fetch_one(&h.db)
1173 .await
1174 .unwrap();
1175 assert_eq!(
1176 stored,
1177 Some(mt_thread_id),
1178 "mt_thread_id should be cached on the issue"
1179 );
1180 }
1181
1182 #[tokio::test]
1183 async fn inbound_new_issue_without_project_skips_bridge() {
1184 let tmp = tempfile::TempDir::new().unwrap();
1185 make_test_repo(tmp.path());
1186 let mock = MockServer::start().await;
1187 // No mount, any call would fail with 404 from wiremock. We assert that
1188 // the issue creation still succeeds and no MT call is made.
1189 let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
1190 // Deliberately do NOT call attach_repo_to_project, repo has no project_id.
1191
1192 h.client.set_bearer_token("test-inbound-secret");
1193 let payload = inbound_payload(
1194 "testowner+testrepo@issues.makenot.work",
1195 "testowner@example.com",
1196 "Orphan issue",
1197 "Body",
1198 );
1199 let resp = h
1200 .client
1201 .post_json("/postmark/inbound-issues", &payload)
1202 .await;
1203 assert_eq!(resp.status, 200);
1204
1205 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
1206 .fetch_one(&h.db)
1207 .await
1208 .unwrap();
1209 assert_eq!(count, 1, "issue should still be created without a project");
1210
1211 let stored: Option<uuid::Uuid> = sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1")
1212 .fetch_one(&h.db)
1213 .await
1214 .unwrap();
1215 assert!(stored.is_none(), "mt_thread_id should remain unset");
1216 }
1217
1218 #[tokio::test]
1219 async fn inbound_issue_reply_bridges_to_mt_thread() {
1220 let tmp = tempfile::TempDir::new().unwrap();
1221 make_test_repo(tmp.path());
1222 let mock = MockServer::start().await;
1223 let mt_thread_id = uuid::Uuid::new_v4();
1224 // Initial issue creation
1225 Mock::given(method("POST"))
1226 .and(path("/internal/threads"))
1227 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1228 "thread_id": mt_thread_id,
1229 "post_id": uuid::Uuid::new_v4(),
1230 "created": true,
1231 })))
1232 .mount(&mock)
1233 .await;
1234 // Reply post
1235 Mock::given(method("POST"))
1236 .and(path(format!("/internal/threads/{mt_thread_id}/posts")))
1237 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1238 "post_id": uuid::Uuid::new_v4(),
1239 })))
1240 .expect(1)
1241 .mount(&mock)
1242 .await;
1243
1244 let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
1245 attach_repo_to_project(&h, "test-project").await;
1246
1247 h.client.set_bearer_token("test-inbound-secret");
1248 // Open the issue
1249 let payload = inbound_payload(
1250 "testowner+testrepo@issues.makenot.work",
1251 "testowner@example.com",
1252 "Reply-bridged",
1253 "Initial body",
1254 );
1255 let resp = h
1256 .client
1257 .post_json("/postmark/inbound-issues", &payload)
1258 .await;
1259 assert_eq!(resp.status, 200);
1260
1261 // Generate reply address for the new issue
1262 let issue_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM issues LIMIT 1")
1263 .fetch_one(&h.db)
1264 .await
1265 .unwrap();
1266 let user_id: uuid::Uuid =
1267 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
1268 .fetch_one(&h.db)
1269 .await
1270 .unwrap();
1271 let reply_addr = makenotwork::email::generate_issue_reply_address(
1272 makenotwork::db::IssueId::from_uuid(issue_id),
1273 makenotwork::db::UserId::from_uuid(user_id),
1274 "test-signing-secret-for-integration-tests",
1275 );
1276
1277 // Send the reply
1278 let payload = inbound_payload(
1279 &reply_addr,
1280 "testowner@example.com",
1281 "Re: Reply-bridged",
1282 "Following up on this",
1283 );
1284 let resp = h
1285 .client
1286 .post_json("/postmark/inbound-issues", &payload)
1287 .await;
1288 assert_eq!(resp.status, 200);
1289 }
1290