Skip to main content

max / makenotwork

36.0 KB · 909 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, 0o100644).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").await;
47 // Visit repo to auto-register it
48 let resp = h.client.get("/git/testowner/testrepo").await;
49 assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text);
50 h
51 }
52
53 /// Set up harness with both git repos and an inbound webhook token.
54 async fn setup_with_inbound(tmp: &tempfile::TempDir) -> TestHarness {
55 let mut h = TestHarness::build(BuildOptions {
56 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
57 postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()),
58 build_trigger_token: Some("test-trigger-secret".to_string()),
59 ..Default::default()
60 })
61 .await;
62 let user_id = h.signup("testowner", "testowner@example.com", "password123").await;
63 // Mark email as verified (required for inbound email processing)
64 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
65 .bind(user_id)
66 .execute(&h.db)
67 .await
68 .unwrap();
69 // Visit repo to auto-register it
70 let resp = h.client.get("/git/testowner/testrepo").await;
71 assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text);
72 h
73 }
74
75 /// Build a Postmark inbound payload JSON string.
76 fn inbound_payload(to: &str, from_email: &str, subject: &str, body: &str) -> String {
77 serde_json::json!({
78 "FromFull": { "Email": from_email, "Name": "" },
79 "To": to,
80 "Subject": subject,
81 "TextBody": body,
82 "MessageID": format!("test-msg-{}", uuid::Uuid::new_v4()),
83 "Headers": []
84 })
85 .to_string()
86 }
87
88 /// Add a commit with the given message to the bare repo, returning (before_oid, after_oid).
89 fn add_commit_to_repo(dir: &std::path::Path, message: &str) -> (String, String) {
90 let bare_path = dir.join("testowner").join("testrepo.git");
91 let repo = git2::Repository::open_bare(&bare_path).unwrap();
92
93 let head = repo.head().unwrap();
94 let parent = head.peel_to_commit().unwrap();
95 let before = parent.id().to_string();
96
97 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
98 let tree = parent.tree().unwrap();
99 let after_oid = repo
100 .commit(Some("refs/heads/main"), &sig, &sig, message, &tree, &[&parent])
101 .unwrap();
102
103 (before, after_oid.to_string())
104 }
105
106 // ══════════════════════════════════════════════════════════════════════
107 // Read-only web UI tests
108 // ══════════════════════════════════════════════════════════════════════
109
110 #[tokio::test]
111 async fn issue_list_loads() {
112 let tmp = tempfile::TempDir::new().unwrap();
113 make_test_repo(tmp.path());
114 let mut h = setup(&tmp).await;
115
116 let resp = h.client.get("/git/testowner/testrepo/issues").await;
117 assert!(resp.status.is_success());
118 assert!(resp.text.contains("Issues"));
119 }
120
121 #[tokio::test]
122 async fn issue_list_no_create_button() {
123 let tmp = tempfile::TempDir::new().unwrap();
124 make_test_repo(tmp.path());
125 let mut h = setup(&tmp).await;
126
127 let resp = h.client.get("/git/testowner/testrepo/issues").await;
128 assert!(resp.status.is_success());
129 assert!(!resp.text.contains("New issue"), "Should have no 'New issue' button");
130 assert!(!resp.text.contains("/issues/new"), "Should have no link to new issue form");
131 }
132
133 #[tokio::test]
134 async fn issue_list_shows_email_address() {
135 let tmp = tempfile::TempDir::new().unwrap();
136 make_test_repo(tmp.path());
137 let mut h = setup(&tmp).await;
138
139 let resp = h.client.get("/git/testowner/testrepo/issues").await;
140 assert!(resp.status.is_success());
141 assert!(
142 resp.text.contains("testowner+testrepo@issues.makenot.work"),
143 "Should show email address for opening issues"
144 );
145 }
146
147 #[tokio::test]
148 async fn issue_detail_loads() {
149 let tmp = tempfile::TempDir::new().unwrap();
150 make_test_repo(tmp.path());
151 let mut h = setup_with_inbound(&tmp).await;
152
153 // Create issue via DB directly
154 let repo_id: uuid::Uuid = sqlx::query_scalar(
155 "SELECT id FROM git_repos WHERE name = 'testrepo'"
156 )
157 .fetch_one(&h.db)
158 .await
159 .unwrap();
160
161 let user_id: uuid::Uuid = sqlx::query_scalar(
162 "SELECT id FROM users WHERE username = 'testowner'"
163 )
164 .fetch_one(&h.db)
165 .await
166 .unwrap();
167
168 sqlx::query(
169 "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>')"
170 )
171 .bind(repo_id)
172 .bind(user_id)
173 .execute(&h.db)
174 .await
175 .unwrap();
176
177 let resp = h.client.get("/git/testowner/testrepo/issues/1").await;
178 assert!(resp.status.is_success());
179 assert!(resp.text.contains("Test issue"));
180 assert!(resp.text.contains("Body text"));
181 }
182
183 #[tokio::test]
184 async fn issue_detail_no_write_ui() {
185 let tmp = tempfile::TempDir::new().unwrap();
186 make_test_repo(tmp.path());
187 let mut h = setup_with_inbound(&tmp).await;
188
189 // Create issue via DB
190 let repo_id: uuid::Uuid = sqlx::query_scalar(
191 "SELECT id FROM git_repos WHERE name = 'testrepo'"
192 )
193 .fetch_one(&h.db)
194 .await
195 .unwrap();
196
197 let user_id: uuid::Uuid = sqlx::query_scalar(
198 "SELECT id FROM users WHERE username = 'testowner'"
199 )
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', '', '')"
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 // No comment form, no edit button, no close/reopen buttons
216 assert!(!resp.text.contains("Add a comment"), "Should have no comment form");
217 assert!(!resp.text.contains("/edit"), "Should have no edit link");
218 assert!(!resp.text.contains("Close issue"), "Should have no close button");
219 assert!(!resp.text.contains("Reopen issue"), "Should have no reopen button");
220 }
221
222 #[tokio::test]
223 async fn issue_detail_shows_email_address() {
224 let tmp = tempfile::TempDir::new().unwrap();
225 make_test_repo(tmp.path());
226 let mut h = setup_with_inbound(&tmp).await;
227
228 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
229 .fetch_one(&h.db).await.unwrap();
230 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
231 .fetch_one(&h.db).await.unwrap();
232
233 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '')")
234 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
235
236 let resp = h.client.get("/git/testowner/testrepo/issues/1").await;
237 assert!(resp.status.is_success());
238 assert!(
239 resp.text.contains("testowner+testrepo@issues.makenot.work"),
240 "Should show email address instructions"
241 );
242 }
243
244 #[tokio::test]
245 async fn nonexistent_issue_returns_404() {
246 let tmp = tempfile::TempDir::new().unwrap();
247 make_test_repo(tmp.path());
248 let mut h = setup(&tmp).await;
249
250 let resp = h.client.get("/git/testowner/testrepo/issues/999").await;
251 assert_eq!(resp.status, 404);
252 }
253
254 #[tokio::test]
255 async fn search_issues() {
256 let tmp = tempfile::TempDir::new().unwrap();
257 make_test_repo(tmp.path());
258 let mut h = setup_with_inbound(&tmp).await;
259
260 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
261 .fetch_one(&h.db).await.unwrap();
262 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
263 .fetch_one(&h.db).await.unwrap();
264
265 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Fix the login bug', '', '')")
266 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
267 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 2, $2, 'Add dark mode', '', '')")
268 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
269
270 let resp = h.client.get("/git/testowner/testrepo/issues?search=login").await;
271 assert!(resp.status.is_success());
272 assert!(resp.text.contains("login bug"), "Should find matching issue");
273 assert!(!resp.text.contains("dark mode"), "Should not show non-matching issue");
274 }
275
276 // ══════════════════════════════════════════════════════════════════════
277 // Inbound email tests
278 // ══════════════════════════════════════════════════════════════════════
279
280 #[tokio::test]
281 async fn inbound_new_issue_creates_issue() {
282 let tmp = tempfile::TempDir::new().unwrap();
283 make_test_repo(tmp.path());
284 let mut h = setup_with_inbound(&tmp).await;
285
286 h.client.set_bearer_token("test-inbound-secret");
287 let payload = inbound_payload(
288 "testowner+testrepo@issues.makenot.work",
289 "testowner@example.com",
290 "Bug via email",
291 "This is the body of the issue.",
292 );
293 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
294 assert_eq!(resp.status, 200, "Inbound should succeed: {}", resp.text);
295
296 // Verify issue appears in DB
297 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
298 .fetch_one(&h.db).await.unwrap();
299 assert_eq!(count, 1, "Should have created one issue");
300
301 // Verify on list page
302 h.client.clear_bearer_token();
303 let resp = h.client.get("/git/testowner/testrepo/issues").await;
304 assert!(resp.text.contains("Bug via email"), "Issue should appear in list");
305 }
306
307 #[tokio::test]
308 async fn inbound_new_issue_requires_verified_sender() {
309 let tmp = tempfile::TempDir::new().unwrap();
310 make_test_repo(tmp.path());
311 let mut h = setup_with_inbound(&tmp).await;
312
313 // Unknown sender
314 h.client.set_bearer_token("test-inbound-secret");
315 let payload = inbound_payload(
316 "testowner+testrepo@issues.makenot.work",
317 "nobody@example.com",
318 "Should fail",
319 "Body",
320 );
321 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
322 assert_eq!(resp.status, 200, "Should still return 200 (silently ignore)");
323
324 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
325 .fetch_one(&h.db).await.unwrap();
326 assert_eq!(count, 0, "No issue should be created for unknown sender");
327 }
328
329 #[tokio::test]
330 async fn inbound_reply_creates_comment() {
331 let tmp = tempfile::TempDir::new().unwrap();
332 make_test_repo(tmp.path());
333 let mut h = setup_with_inbound(&tmp).await;
334
335 // Create issue via DB
336 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
337 .fetch_one(&h.db).await.unwrap();
338 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
339 .fetch_one(&h.db).await.unwrap();
340 let issue_id: uuid::Uuid = sqlx::query_scalar(
341 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id"
342 )
343 .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap();
344
345 // Generate a valid reply address
346 let reply_addr = makenotwork::email::generate_issue_reply_address(
347 makenotwork::db::IssueId::from(issue_id),
348 makenotwork::db::UserId::from(user_id),
349 "test-signing-secret-for-integration-tests",
350 );
351
352 h.client.set_bearer_token("test-inbound-secret");
353 let payload = inbound_payload(
354 &reply_addr,
355 "testowner@example.com",
356 "Re: Test",
357 "This is my reply.\n\n> Previous message\n> more quoted text",
358 );
359 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
360 assert_eq!(resp.status, 200);
361
362 // Check comment was created (with quoted text stripped)
363 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
364 .bind(issue_id).fetch_one(&h.db).await.unwrap();
365 assert_eq!(count, 1, "Should have created one comment");
366
367 let body: String = sqlx::query_scalar("SELECT body_markdown FROM issue_comments WHERE issue_id = $1")
368 .bind(issue_id).fetch_one(&h.db).await.unwrap();
369 assert!(body.contains("This is my reply"), "Comment should have reply text");
370 assert!(!body.contains("Previous message"), "Quoted text should be stripped");
371 }
372
373 #[tokio::test]
374 async fn inbound_reply_invalid_token_rejected() {
375 let tmp = tempfile::TempDir::new().unwrap();
376 make_test_repo(tmp.path());
377 let mut h = setup_with_inbound(&tmp).await;
378
379 // Create issue via DB
380 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
381 .fetch_one(&h.db).await.unwrap();
382 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
383 .fetch_one(&h.db).await.unwrap();
384 let issue_id: uuid::Uuid = sqlx::query_scalar(
385 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id"
386 )
387 .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap();
388
389 // Forge a reply address with wrong HMAC
390 let bad_addr = format!("issue+{}.{}.deadbeefdeadbeef@reply.makenot.work", issue_id, user_id);
391
392 h.client.set_bearer_token("test-inbound-secret");
393 let payload = inbound_payload(&bad_addr, "testowner@example.com", "Re: Test", "Hack attempt");
394 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
395 assert_eq!(resp.status, 200, "Should return 200 but silently ignore");
396
397 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
398 .bind(issue_id).fetch_one(&h.db).await.unwrap();
399 assert_eq!(count, 0, "No comment should be created for invalid token");
400 }
401
402 #[tokio::test]
403 async fn inbound_reply_wrong_sender_rejected() {
404 let tmp = tempfile::TempDir::new().unwrap();
405 make_test_repo(tmp.path());
406 let mut h = setup_with_inbound(&tmp).await;
407
408 // Create a second verified user
409 h.client.post_form("/logout", "").await;
410 let other_id = h.signup("otheruser", "other@example.com", "password123").await;
411 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
412 .bind(other_id).execute(&h.db).await.unwrap();
413
414 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
415 .fetch_one(&h.db).await.unwrap();
416 let owner_user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
417 .fetch_one(&h.db).await.unwrap();
418 let issue_id: uuid::Uuid = sqlx::query_scalar(
419 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id"
420 )
421 .bind(repo_id).bind(owner_user_id).fetch_one(&h.db).await.unwrap();
422
423 // Generate reply address for owner, but send from other user
424 let reply_addr = makenotwork::email::generate_issue_reply_address(
425 makenotwork::db::IssueId::from(issue_id),
426 makenotwork::db::UserId::from(owner_user_id),
427 "test-signing-secret-for-integration-tests",
428 );
429
430 h.client.set_bearer_token("test-inbound-secret");
431 let payload = inbound_payload(&reply_addr, "other@example.com", "Re: Test", "Wrong sender");
432 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
433 assert_eq!(resp.status, 200);
434
435 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
436 .bind(issue_id).fetch_one(&h.db).await.unwrap();
437 assert_eq!(count, 0, "No comment: sender doesn't match token user_id");
438 }
439
440 #[tokio::test]
441 async fn inbound_requires_auth_token() {
442 let tmp = tempfile::TempDir::new().unwrap();
443 make_test_repo(tmp.path());
444 let mut h = setup_with_inbound(&tmp).await;
445
446 // No bearer token
447 let payload = inbound_payload(
448 "testowner+testrepo@issues.makenot.work",
449 "testowner@example.com",
450 "No auth",
451 "Body",
452 );
453 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
454 assert_eq!(resp.status, 401, "Should reject unauthenticated request");
455 }
456
457 // ══════════════════════════════════════════════════════════════════════
458 // Old write routes return 404/405
459 // ══════════════════════════════════════════════════════════════════════
460
461 #[tokio::test]
462 async fn old_write_routes_return_404() {
463 let tmp = tempfile::TempDir::new().unwrap();
464 make_test_repo(tmp.path());
465 let mut h = setup(&tmp).await;
466
467 // These routes have been removed
468 let removed = [
469 ("GET", "/git/testowner/testrepo/issues/new"),
470 ("POST", "/git/testowner/testrepo/issues/1/close"),
471 ("POST", "/git/testowner/testrepo/issues/1/reopen"),
472 ("POST", "/git/testowner/testrepo/issues/1/comment"),
473 ("POST", "/git/testowner/testrepo/issues/1/comment/00000000-0000-0000-0000-000000000000/delete"),
474 ("GET", "/git/testowner/testrepo/issues/1/edit"),
475 ("POST", "/git/testowner/testrepo/issues/1/edit"),
476 ("GET", "/git/testowner/testrepo/issues/labels"),
477 ("POST", "/git/testowner/testrepo/issues/labels"),
478 ];
479
480 for (method, path) in removed {
481 let resp = match method {
482 "GET" => h.client.get(path).await,
483 "POST" => h.client.post_form(path, "").await,
484 _ => unreachable!(),
485 };
486 // 404 = route removed, 405 = method not allowed, 400 = path param parse error
487 // (e.g. "new" can't parse as i32 for the {number} param)
488 assert!(
489 resp.status == 400 || resp.status == 404 || resp.status == 405,
490 "{} {} should be 400/404/405 but got {}",
491 method, path, resp.status
492 );
493 }
494 }
495
496 // ══════════════════════════════════════════════════════════════════════
497 // Push refs
498 // ══════════════════════════════════════════════════════════════════════
499
500 /// Set up harness with both git repos and a build trigger token.
501 async fn setup_with_push(tmp: &tempfile::TempDir) -> TestHarness {
502 let mut h = TestHarness::build(BuildOptions {
503 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
504 build_trigger_token: Some("test-trigger-secret".to_string()),
505 ..Default::default()
506 })
507 .await;
508 h.signup("testowner", "testowner@example.com", "password123").await;
509 let resp = h.client.get("/git/testowner/testrepo").await;
510 assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text);
511 h
512 }
513
514 #[tokio::test]
515 async fn process_push_closes_issue() {
516 let tmp = tempfile::TempDir::new().unwrap();
517 make_test_repo(tmp.path());
518 let mut h = setup_with_push(&tmp).await;
519
520 // Create an issue via DB
521 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
522 .fetch_one(&h.db).await.unwrap();
523 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
524 .fetch_one(&h.db).await.unwrap();
525 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')")
526 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
527
528 let (before, after) = add_commit_to_repo(tmp.path(), "Fix the bug\n\nFixes #1");
529
530 h.client.set_bearer_token(&push_token("testowner", "testrepo"));
531 let body = serde_json::json!({
532 "repo_owner": "testowner",
533 "repo_name": "testrepo",
534 "ref_name": "main",
535 "before": before,
536 "after": after,
537 });
538 let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await;
539 assert_eq!(resp.status, 200, "process-push should succeed: {}", resp.text);
540
541 let json: serde_json::Value = resp.json();
542 assert_eq!(json["processed"], 1);
543
544 // Verify issue is now closed
545 let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE number = 1 AND repo_id = $1")
546 .bind(repo_id).fetch_one(&h.db).await.unwrap();
547 assert_eq!(status, "closed", "Issue should be closed");
548 }
549
550 #[tokio::test]
551 async fn push_refs_reopens_issue() {
552 let tmp = tempfile::TempDir::new().unwrap();
553 make_test_repo(tmp.path());
554 let mut h = setup_with_push(&tmp).await;
555
556 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
557 .fetch_one(&h.db).await.unwrap();
558 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
559 .fetch_one(&h.db).await.unwrap();
560
561 // Create a closed issue
562 sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html, status) VALUES ($1, 1, $2, 'Closed bug', '', '', 'closed')")
563 .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap();
564
565 let (before, after) = add_commit_to_repo(tmp.path(), "Not actually fixed\n\nReopens #1");
566
567 h.client.set_bearer_token(&push_token("testowner", "testrepo"));
568 let body = serde_json::json!({
569 "repo_owner": "testowner",
570 "repo_name": "testrepo",
571 "ref_name": "main",
572 "before": before,
573 "after": after,
574 });
575 let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await;
576 assert_eq!(resp.status, 200);
577
578 let json: serde_json::Value = resp.json();
579 assert_eq!(json["processed"], 1);
580
581 // Verify issue is now open
582 let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE number = 1 AND repo_id = $1")
583 .bind(repo_id).fetch_one(&h.db).await.unwrap();
584 assert_eq!(status, "open", "Issue should be reopened");
585 }
586
587 #[tokio::test]
588 async fn process_push_references_issue() {
589 let tmp = tempfile::TempDir::new().unwrap();
590 make_test_repo(tmp.path());
591 let mut h = setup_with_push(&tmp).await;
592
593 let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
594 .fetch_one(&h.db).await.unwrap();
595 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
596 .fetch_one(&h.db).await.unwrap();
597 let issue_id: uuid::Uuid = sqlx::query_scalar(
598 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Tracked', '', '') RETURNING id"
599 )
600 .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap();
601
602 let (before, after) = add_commit_to_repo(tmp.path(), "Related work\n\nRefs #1");
603
604 h.client.set_bearer_token(&push_token("testowner", "testrepo"));
605 let body = serde_json::json!({
606 "repo_owner": "testowner",
607 "repo_name": "testrepo",
608 "ref_name": "main",
609 "before": before,
610 "after": after,
611 });
612 let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await;
613 assert_eq!(resp.status, 200);
614
615 // Issue should still be open
616 let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE id = $1")
617 .bind(issue_id).fetch_one(&h.db).await.unwrap();
618 assert_eq!(status, "open", "Issue should remain open on reference");
619
620 // Should have a comment
621 let comment_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
622 .bind(issue_id).fetch_one(&h.db).await.unwrap();
623 assert_eq!(comment_count, 1, "Should have a reference comment");
624 }
625
626 #[tokio::test]
627 async fn process_push_requires_auth() {
628 let tmp = tempfile::TempDir::new().unwrap();
629 make_test_repo(tmp.path());
630 let mut h = setup_with_push(&tmp).await;
631
632 let body = serde_json::json!({
633 "repo_owner": "testowner",
634 "repo_name": "testrepo",
635 "ref_name": "main",
636 "before": "0000000000000000000000000000000000000000",
637 "after": "0000000000000000000000000000000000000001",
638 });
639 let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await;
640 assert_eq!(resp.status, 403, "Should reject unauthenticated request");
641 }
642
643 // ══════════════════════════════════════════════════════════════════════
644 // Repo settings (unchanged — still web-based)
645 // ══════════════════════════════════════════════════════════════════════
646
647 #[tokio::test]
648 async fn repo_settings_page_loads_for_owner() {
649 let tmp = tempfile::TempDir::new().unwrap();
650 make_test_repo(tmp.path());
651 let mut h = setup(&tmp).await;
652
653 let resp = h.client.get("/git/testowner/testrepo/settings").await;
654 assert!(resp.status.is_success(), "Settings should load for owner: {}", resp.status);
655 assert!(resp.text.contains("Repository Settings"));
656 }
657
658 #[tokio::test]
659 async fn repo_settings_denied_for_non_owner() {
660 let tmp = tempfile::TempDir::new().unwrap();
661 make_test_repo(tmp.path());
662 let mut h = setup(&tmp).await;
663
664 h.client.post_form("/logout", "").await;
665 h.signup("otheruser", "other@example.com", "password123").await;
666 h.login("otheruser", "password123").await;
667
668 let resp = h.client.get("/git/testowner/testrepo/settings").await;
669 assert_eq!(resp.status, 403, "Non-owner should be forbidden");
670 }
671
672 #[tokio::test]
673 async fn notify_issues_preference_roundtrip() {
674 let mut h = TestHarness::new().await;
675 h.signup("prefuser", "prefuser@example.com", "password123").await;
676
677 let row: (bool,) =
678 sqlx::query_as("SELECT notify_issues FROM users WHERE username = 'prefuser'")
679 .fetch_one(&h.db)
680 .await
681 .unwrap();
682 assert!(row.0, "notify_issues should default to true");
683
684 let resp = h.client.put_form("/api/users/me/preferences", "").await;
685 assert!(resp.status.is_success());
686
687 let row: (bool,) =
688 sqlx::query_as("SELECT notify_issues FROM users WHERE username = 'prefuser'")
689 .fetch_one(&h.db)
690 .await
691 .unwrap();
692 assert!(!row.0, "notify_issues should be false after saving with no checkboxes");
693
694 let resp = h.client.put_form("/api/users/me/preferences", "notify_issues=on").await;
695 assert!(resp.status.is_success());
696
697 let row: (bool,) =
698 sqlx::query_as("SELECT notify_issues FROM users WHERE username = 'prefuser'")
699 .fetch_one(&h.db)
700 .await
701 .unwrap();
702 assert!(row.0, "notify_issues should be true again");
703 }
704
705 #[tokio::test]
706 async fn repo_page_shows_issues_nav_link() {
707 let tmp = tempfile::TempDir::new().unwrap();
708 make_test_repo(tmp.path());
709 let mut h = setup(&tmp).await;
710
711 let resp = h.client.get("/git/testowner/testrepo").await;
712 assert!(resp.status.is_success());
713 assert!(resp.text.contains("/issues"), "Repo page should have issues link");
714 }
715
716 // ══════════════════════════════════════════════════════════════════════
717 // Multithreaded bridge — issues mirror into a forum thread
718 // ══════════════════════════════════════════════════════════════════════
719
720 async fn setup_with_inbound_and_mt(
721 tmp: &tempfile::TempDir,
722 mt_url: String,
723 ) -> TestHarness {
724 let mut h = TestHarness::build(BuildOptions {
725 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
726 postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()),
727 build_trigger_token: Some("test-trigger-secret".to_string()),
728 mt_base_url: Some(mt_url),
729 internal_shared_secret: Some("test-mt-secret".to_string()),
730 ..Default::default()
731 })
732 .await;
733 let user_id = h.signup("testowner", "testowner@example.com", "password123").await;
734 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
735 .bind(user_id)
736 .execute(&h.db)
737 .await
738 .unwrap();
739 let resp = h.client.get("/git/testowner/testrepo").await;
740 assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text);
741 h
742 }
743
744 /// Link the test repo to a project so the bridge has a community slug.
745 async fn attach_repo_to_project(h: &TestHarness, project_slug: &str) -> uuid::Uuid {
746 let user_id: uuid::Uuid = sqlx::query_scalar(
747 "SELECT id FROM users WHERE username = 'testowner'",
748 )
749 .fetch_one(&h.db)
750 .await
751 .unwrap();
752 let project_id: uuid::Uuid = sqlx::query_scalar(
753 "INSERT INTO projects (user_id, title, slug)
754 VALUES ($1, 'Test Project', $2) RETURNING id",
755 )
756 .bind(user_id)
757 .bind(project_slug)
758 .fetch_one(&h.db)
759 .await
760 .unwrap();
761 sqlx::query("UPDATE git_repos SET project_id = $1 WHERE name = 'testrepo'")
762 .bind(project_id)
763 .execute(&h.db)
764 .await
765 .unwrap();
766 project_id
767 }
768
769 #[tokio::test]
770 async fn inbound_new_issue_bridges_to_mt() {
771 let tmp = tempfile::TempDir::new().unwrap();
772 make_test_repo(tmp.path());
773 let mock = MockServer::start().await;
774 let mt_thread_id = uuid::Uuid::new_v4();
775 Mock::given(method("POST"))
776 .and(path("/internal/threads"))
777 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
778 "thread_id": mt_thread_id,
779 "post_id": uuid::Uuid::new_v4(),
780 "created": true,
781 })))
782 .expect(1)
783 .mount(&mock)
784 .await;
785
786 let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
787 attach_repo_to_project(&h, "test-project").await;
788
789 h.client.set_bearer_token("test-inbound-secret");
790 let payload = inbound_payload(
791 "testowner+testrepo@issues.makenot.work",
792 "testowner@example.com",
793 "Bridge me",
794 "Body text",
795 );
796 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
797 assert_eq!(resp.status, 200, "inbound: {}", resp.text);
798
799 let stored: Option<uuid::Uuid> =
800 sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1")
801 .fetch_one(&h.db)
802 .await
803 .unwrap();
804 assert_eq!(stored, Some(mt_thread_id), "mt_thread_id should be cached on the issue");
805 }
806
807 #[tokio::test]
808 async fn inbound_new_issue_without_project_skips_bridge() {
809 let tmp = tempfile::TempDir::new().unwrap();
810 make_test_repo(tmp.path());
811 let mock = MockServer::start().await;
812 // No mount — any call would fail with 404 from wiremock. We assert that
813 // the issue creation still succeeds and no MT call is made.
814 let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
815 // Deliberately do NOT call attach_repo_to_project — repo has no project_id.
816
817 h.client.set_bearer_token("test-inbound-secret");
818 let payload = inbound_payload(
819 "testowner+testrepo@issues.makenot.work",
820 "testowner@example.com",
821 "Orphan issue",
822 "Body",
823 );
824 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
825 assert_eq!(resp.status, 200);
826
827 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues")
828 .fetch_one(&h.db)
829 .await
830 .unwrap();
831 assert_eq!(count, 1, "issue should still be created without a project");
832
833 let stored: Option<uuid::Uuid> =
834 sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1")
835 .fetch_one(&h.db)
836 .await
837 .unwrap();
838 assert!(stored.is_none(), "mt_thread_id should remain unset");
839 }
840
841 #[tokio::test]
842 async fn inbound_issue_reply_bridges_to_mt_thread() {
843 let tmp = tempfile::TempDir::new().unwrap();
844 make_test_repo(tmp.path());
845 let mock = MockServer::start().await;
846 let mt_thread_id = uuid::Uuid::new_v4();
847 // Initial issue creation
848 Mock::given(method("POST"))
849 .and(path("/internal/threads"))
850 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
851 "thread_id": mt_thread_id,
852 "post_id": uuid::Uuid::new_v4(),
853 "created": true,
854 })))
855 .mount(&mock)
856 .await;
857 // Reply post
858 Mock::given(method("POST"))
859 .and(path(format!("/internal/threads/{}/posts", mt_thread_id)))
860 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
861 "post_id": uuid::Uuid::new_v4(),
862 })))
863 .expect(1)
864 .mount(&mock)
865 .await;
866
867 let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await;
868 attach_repo_to_project(&h, "test-project").await;
869
870 h.client.set_bearer_token("test-inbound-secret");
871 // Open the issue
872 let payload = inbound_payload(
873 "testowner+testrepo@issues.makenot.work",
874 "testowner@example.com",
875 "Reply-bridged",
876 "Initial body",
877 );
878 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
879 assert_eq!(resp.status, 200);
880
881 // Generate reply address for the new issue
882 let issue_id: uuid::Uuid =
883 sqlx::query_scalar("SELECT id FROM issues LIMIT 1")
884 .fetch_one(&h.db)
885 .await
886 .unwrap();
887 let user_id: uuid::Uuid =
888 sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'")
889 .fetch_one(&h.db)
890 .await
891 .unwrap();
892 let reply_addr = makenotwork::email::generate_issue_reply_address(
893 makenotwork::db::IssueId::from_uuid(issue_id),
894 makenotwork::db::UserId::from_uuid(user_id),
895 "test-signing-secret-for-integration-tests",
896 );
897
898 // Send the reply
899 let payload = inbound_payload(
900 &reply_addr,
901 "testowner@example.com",
902 "Re: Reply-bridged",
903 "Following up on this",
904 );
905 let resp = h.client.post_json("/postmark/inbound-issues", &payload).await;
906 assert_eq!(resp.status, 200);
907 }
908
909