Skip to main content

max / makenotwork

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