Skip to main content

max / makenotwork

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