//! Git issue tracker integration tests. //! //! Tests the email-first issue tracker: read-only web UI, inbound email //! issue creation and replies, push_refs close/reopen, and that old //! write routes return 404/405. use crate::harness::{BuildOptions, TestHarness}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// Compute the per-repo HMAC that the push endpoint expects. fn push_token(owner: &str, repo: &str) -> String { makenotwork::build_runner::repo_hmac("test-trigger-secret", owner, repo) } /// Create a temp bare repo at `{dir}/testowner/testrepo.git` with one commit on "main". fn make_test_repo(dir: &std::path::Path) { let bare_path = dir.join("testowner").join("testrepo.git"); std::fs::create_dir_all(&bare_path).unwrap(); let bare_repo = git2::Repository::init_bare(&bare_path).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let readme_oid = bare_repo.blob(b"# Test Repo\n").unwrap(); let mut root_tb = bare_repo.treebuilder(None).unwrap(); root_tb.insert("README.md", readme_oid, 0o100644).unwrap(); let root_tree_oid = root_tb.write().unwrap(); let root_tree = bare_repo.find_tree(root_tree_oid).unwrap(); bare_repo .commit( Some("refs/heads/main"), &sig, &sig, "Initial commit", &root_tree, &[], ) .unwrap(); bare_repo.set_head("refs/heads/main").unwrap(); } /// Set up harness with git repos and owner user logged in. async fn setup(tmp: &tempfile::TempDir) -> TestHarness { let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; h.signup("testowner", "testowner@example.com", "password123").await; // Visit repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text); h } /// Set up harness with both git repos and an inbound webhook token. async fn setup_with_inbound(tmp: &tempfile::TempDir) -> TestHarness { let mut h = TestHarness::build(BuildOptions { git_repos_path: Some(tmp.path().to_str().unwrap().to_string()), postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()), build_trigger_token: Some("test-trigger-secret".to_string()), ..Default::default() }) .await; let user_id = h.signup("testowner", "testowner@example.com", "password123").await; // Mark email as verified (required for inbound email processing) sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(user_id) .execute(&h.db) .await .unwrap(); // Visit repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text); h } /// Build a Postmark inbound payload JSON string. fn inbound_payload(to: &str, from_email: &str, subject: &str, body: &str) -> String { serde_json::json!({ "FromFull": { "Email": from_email, "Name": "" }, "To": to, "Subject": subject, "TextBody": body, "MessageID": format!("test-msg-{}", uuid::Uuid::new_v4()), "Headers": [] }) .to_string() } /// Add a commit with the given message to the bare repo, returning (before_oid, after_oid). fn add_commit_to_repo(dir: &std::path::Path, message: &str) -> (String, String) { let bare_path = dir.join("testowner").join("testrepo.git"); let repo = git2::Repository::open_bare(&bare_path).unwrap(); let head = repo.head().unwrap(); let parent = head.peel_to_commit().unwrap(); let before = parent.id().to_string(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let tree = parent.tree().unwrap(); let after_oid = repo .commit(Some("refs/heads/main"), &sig, &sig, message, &tree, &[&parent]) .unwrap(); (before, after_oid.to_string()) } // ══════════════════════════════════════════════════════════════════════ // Read-only web UI tests // ══════════════════════════════════════════════════════════════════════ #[tokio::test] async fn issue_list_loads() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/issues").await; assert!(resp.status.is_success()); assert!(resp.text.contains("Issues")); } #[tokio::test] async fn issue_list_no_create_button() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/issues").await; assert!(resp.status.is_success()); assert!(!resp.text.contains("New issue"), "Should have no 'New issue' button"); assert!(!resp.text.contains("/issues/new"), "Should have no link to new issue form"); } #[tokio::test] async fn issue_list_shows_email_address() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/issues").await; assert!(resp.status.is_success()); assert!( resp.text.contains("testowner+testrepo@issues.makenot.work"), "Should show email address for opening issues" ); } #[tokio::test] async fn issue_detail_loads() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // Create issue via DB directly let repo_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM git_repos WHERE name = 'testrepo'" ) .fetch_one(&h.db) .await .unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM users WHERE username = 'testowner'" ) .fetch_one(&h.db) .await .unwrap(); sqlx::query( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test issue', 'Body text', '

Body text

')" ) .bind(repo_id) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/git/testowner/testrepo/issues/1").await; assert!(resp.status.is_success()); assert!(resp.text.contains("Test issue")); assert!(resp.text.contains("Body text")); } #[tokio::test] async fn issue_detail_no_write_ui() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // Create issue via DB let repo_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM git_repos WHERE name = 'testrepo'" ) .fetch_one(&h.db) .await .unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM users WHERE username = 'testowner'" ) .fetch_one(&h.db) .await .unwrap(); sqlx::query( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '')" ) .bind(repo_id) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/git/testowner/testrepo/issues/1").await; assert!(resp.status.is_success()); // No comment form, no edit button, no close/reopen buttons assert!(!resp.text.contains("Add a comment"), "Should have no comment form"); assert!(!resp.text.contains("/edit"), "Should have no edit link"); assert!(!resp.text.contains("Close issue"), "Should have no close button"); assert!(!resp.text.contains("Reopen issue"), "Should have no reopen button"); } #[tokio::test] async fn issue_detail_shows_email_address() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '')") .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap(); let resp = h.client.get("/git/testowner/testrepo/issues/1").await; assert!(resp.status.is_success()); assert!( resp.text.contains("testowner+testrepo@issues.makenot.work"), "Should show email address instructions" ); } #[tokio::test] async fn nonexistent_issue_returns_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/issues/999").await; assert_eq!(resp.status, 404); } #[tokio::test] async fn search_issues() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Fix the login bug', '', '')") .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap(); sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 2, $2, 'Add dark mode', '', '')") .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap(); let resp = h.client.get("/git/testowner/testrepo/issues?search=login").await; assert!(resp.status.is_success()); assert!(resp.text.contains("login bug"), "Should find matching issue"); assert!(!resp.text.contains("dark mode"), "Should not show non-matching issue"); } // ══════════════════════════════════════════════════════════════════════ // Inbound email tests // ══════════════════════════════════════════════════════════════════════ #[tokio::test] async fn inbound_new_issue_creates_issue() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload( "testowner+testrepo@issues.makenot.work", "testowner@example.com", "Bug via email", "This is the body of the issue.", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200, "Inbound should succeed: {}", resp.text); // Verify issue appears in DB let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues") .fetch_one(&h.db).await.unwrap(); assert_eq!(count, 1, "Should have created one issue"); // Verify on list page h.client.clear_bearer_token(); let resp = h.client.get("/git/testowner/testrepo/issues").await; assert!(resp.text.contains("Bug via email"), "Issue should appear in list"); } #[tokio::test] async fn inbound_new_issue_requires_verified_sender() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // Unknown sender h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload( "testowner+testrepo@issues.makenot.work", "nobody@example.com", "Should fail", "Body", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200, "Should still return 200 (silently ignore)"); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues") .fetch_one(&h.db).await.unwrap(); assert_eq!(count, 0, "No issue should be created for unknown sender"); } #[tokio::test] async fn inbound_reply_creates_comment() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // Create issue via DB let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); let issue_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id" ) .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap(); // Generate a valid reply address let reply_addr = makenotwork::email::generate_issue_reply_address( makenotwork::db::IssueId::from(issue_id), makenotwork::db::UserId::from(user_id), "test-signing-secret-for-integration-tests", ); h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload( &reply_addr, "testowner@example.com", "Re: Test", "This is my reply.\n\n> Previous message\n> more quoted text", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200); // Check comment was created (with quoted text stripped) let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1") .bind(issue_id).fetch_one(&h.db).await.unwrap(); assert_eq!(count, 1, "Should have created one comment"); let body: String = sqlx::query_scalar("SELECT body_markdown FROM issue_comments WHERE issue_id = $1") .bind(issue_id).fetch_one(&h.db).await.unwrap(); assert!(body.contains("This is my reply"), "Comment should have reply text"); assert!(!body.contains("Previous message"), "Quoted text should be stripped"); } #[tokio::test] async fn inbound_reply_invalid_token_rejected() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // Create issue via DB let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); let issue_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id" ) .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap(); // Forge a reply address with wrong HMAC let bad_addr = format!("issue+{}.{}.deadbeefdeadbeef@reply.makenot.work", issue_id, user_id); h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload(&bad_addr, "testowner@example.com", "Re: Test", "Hack attempt"); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200, "Should return 200 but silently ignore"); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1") .bind(issue_id).fetch_one(&h.db).await.unwrap(); assert_eq!(count, 0, "No comment should be created for invalid token"); } #[tokio::test] async fn inbound_reply_wrong_sender_rejected() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // Create a second verified user h.client.post_form("/logout", "").await; let other_id = h.signup("otheruser", "other@example.com", "password123").await; sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(other_id).execute(&h.db).await.unwrap(); let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let owner_user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); let issue_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Test', '', '') RETURNING id" ) .bind(repo_id).bind(owner_user_id).fetch_one(&h.db).await.unwrap(); // Generate reply address for owner, but send from other user let reply_addr = makenotwork::email::generate_issue_reply_address( makenotwork::db::IssueId::from(issue_id), makenotwork::db::UserId::from(owner_user_id), "test-signing-secret-for-integration-tests", ); h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload(&reply_addr, "other@example.com", "Re: Test", "Wrong sender"); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1") .bind(issue_id).fetch_one(&h.db).await.unwrap(); assert_eq!(count, 0, "No comment: sender doesn't match token user_id"); } #[tokio::test] async fn inbound_requires_auth_token() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_inbound(&tmp).await; // No bearer token let payload = inbound_payload( "testowner+testrepo@issues.makenot.work", "testowner@example.com", "No auth", "Body", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 401, "Should reject unauthenticated request"); } // ══════════════════════════════════════════════════════════════════════ // Old write routes return 404/405 // ══════════════════════════════════════════════════════════════════════ #[tokio::test] async fn old_write_routes_return_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; // These routes have been removed let removed = [ ("GET", "/git/testowner/testrepo/issues/new"), ("POST", "/git/testowner/testrepo/issues/1/close"), ("POST", "/git/testowner/testrepo/issues/1/reopen"), ("POST", "/git/testowner/testrepo/issues/1/comment"), ("POST", "/git/testowner/testrepo/issues/1/comment/00000000-0000-0000-0000-000000000000/delete"), ("GET", "/git/testowner/testrepo/issues/1/edit"), ("POST", "/git/testowner/testrepo/issues/1/edit"), ("GET", "/git/testowner/testrepo/issues/labels"), ("POST", "/git/testowner/testrepo/issues/labels"), ]; for (method, path) in removed { let resp = match method { "GET" => h.client.get(path).await, "POST" => h.client.post_form(path, "").await, _ => unreachable!(), }; // 404 = route removed, 405 = method not allowed, 400 = path param parse error // (e.g. "new" can't parse as i32 for the {number} param) assert!( resp.status == 400 || resp.status == 404 || resp.status == 405, "{} {} should be 400/404/405 but got {}", method, path, resp.status ); } } // ══════════════════════════════════════════════════════════════════════ // Push refs // ══════════════════════════════════════════════════════════════════════ /// Set up harness with both git repos and a build trigger token. async fn setup_with_push(tmp: &tempfile::TempDir) -> TestHarness { let mut h = TestHarness::build(BuildOptions { git_repos_path: Some(tmp.path().to_str().unwrap().to_string()), build_trigger_token: Some("test-trigger-secret".to_string()), ..Default::default() }) .await; h.signup("testowner", "testowner@example.com", "password123").await; let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text); h } #[tokio::test] async fn process_push_closes_issue() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_push(&tmp).await; // Create an issue via DB let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); 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')") .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap(); let (before, after) = add_commit_to_repo(tmp.path(), "Fix the bug\n\nFixes #1"); h.client.set_bearer_token(&push_token("testowner", "testrepo")); let body = serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "main", "before": before, "after": after, }); let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await; assert_eq!(resp.status, 200, "process-push should succeed: {}", resp.text); let json: serde_json::Value = resp.json(); assert_eq!(json["processed"], 1); // Verify issue is now closed let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE number = 1 AND repo_id = $1") .bind(repo_id).fetch_one(&h.db).await.unwrap(); assert_eq!(status, "closed", "Issue should be closed"); } #[tokio::test] async fn push_refs_reopens_issue() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_push(&tmp).await; let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); // Create a closed issue sqlx::query("INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html, status) VALUES ($1, 1, $2, 'Closed bug', '', '', 'closed')") .bind(repo_id).bind(user_id).execute(&h.db).await.unwrap(); let (before, after) = add_commit_to_repo(tmp.path(), "Not actually fixed\n\nReopens #1"); h.client.set_bearer_token(&push_token("testowner", "testrepo")); let body = serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "main", "before": before, "after": after, }); let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await; assert_eq!(resp.status, 200); let json: serde_json::Value = resp.json(); assert_eq!(json["processed"], 1); // Verify issue is now open let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE number = 1 AND repo_id = $1") .bind(repo_id).fetch_one(&h.db).await.unwrap(); assert_eq!(status, "open", "Issue should be reopened"); } #[tokio::test] async fn process_push_references_issue() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_push(&tmp).await; let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db).await.unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db).await.unwrap(); let issue_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'Tracked', '', '') RETURNING id" ) .bind(repo_id).bind(user_id).fetch_one(&h.db).await.unwrap(); let (before, after) = add_commit_to_repo(tmp.path(), "Related work\n\nRefs #1"); h.client.set_bearer_token(&push_token("testowner", "testrepo")); let body = serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "main", "before": before, "after": after, }); let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await; assert_eq!(resp.status, 200); // Issue should still be open let status: String = sqlx::query_scalar("SELECT status FROM issues WHERE id = $1") .bind(issue_id).fetch_one(&h.db).await.unwrap(); assert_eq!(status, "open", "Issue should remain open on reference"); // Should have a comment let comment_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1") .bind(issue_id).fetch_one(&h.db).await.unwrap(); assert_eq!(comment_count, 1, "Should have a reference comment"); } #[tokio::test] async fn process_push_requires_auth() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_with_push(&tmp).await; let body = serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "main", "before": "0000000000000000000000000000000000000000", "after": "0000000000000000000000000000000000000001", }); let resp = h.client.post_json("/api/internal/issues/process-push", &body.to_string()).await; assert_eq!(resp.status, 403, "Should reject unauthenticated request"); } // ══════════════════════════════════════════════════════════════════════ // Repo settings (unchanged — still web-based) // ══════════════════════════════════════════════════════════════════════ #[tokio::test] async fn repo_settings_page_loads_for_owner() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/settings").await; assert!(resp.status.is_success(), "Settings should load for owner: {}", resp.status); assert!(resp.text.contains("Repository Settings")); } #[tokio::test] async fn repo_settings_denied_for_non_owner() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; h.client.post_form("/logout", "").await; h.signup("otheruser", "other@example.com", "password123").await; h.login("otheruser", "password123").await; let resp = h.client.get("/git/testowner/testrepo/settings").await; assert_eq!(resp.status, 403, "Non-owner should be forbidden"); } #[tokio::test] async fn notify_issues_preference_roundtrip() { let mut h = TestHarness::new().await; h.signup("prefuser", "prefuser@example.com", "password123").await; let row: (bool,) = sqlx::query_as("SELECT notify_issues FROM users WHERE username = 'prefuser'") .fetch_one(&h.db) .await .unwrap(); assert!(row.0, "notify_issues should default to true"); let resp = h.client.put_form("/api/users/me/preferences", "").await; assert!(resp.status.is_success()); let row: (bool,) = sqlx::query_as("SELECT notify_issues FROM users WHERE username = 'prefuser'") .fetch_one(&h.db) .await .unwrap(); assert!(!row.0, "notify_issues should be false after saving with no checkboxes"); let resp = h.client.put_form("/api/users/me/preferences", "notify_issues=on").await; assert!(resp.status.is_success()); let row: (bool,) = sqlx::query_as("SELECT notify_issues FROM users WHERE username = 'prefuser'") .fetch_one(&h.db) .await .unwrap(); assert!(row.0, "notify_issues should be true again"); } #[tokio::test] async fn repo_page_shows_issues_nav_link() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup(&tmp).await; let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); assert!(resp.text.contains("/issues"), "Repo page should have issues link"); } // ══════════════════════════════════════════════════════════════════════ // Multithreaded bridge — issues mirror into a forum thread // ══════════════════════════════════════════════════════════════════════ async fn setup_with_inbound_and_mt( tmp: &tempfile::TempDir, mt_url: String, ) -> TestHarness { let mut h = TestHarness::build(BuildOptions { git_repos_path: Some(tmp.path().to_str().unwrap().to_string()), postmark_inbound_webhook_token: Some("test-inbound-secret".to_string()), build_trigger_token: Some("test-trigger-secret".to_string()), mt_base_url: Some(mt_url), internal_shared_secret: Some("test-mt-secret".to_string()), ..Default::default() }) .await; let user_id = h.signup("testowner", "testowner@example.com", "password123").await; sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success(), "Repo setup failed: {}", resp.text); h } /// Link the test repo to a project so the bridge has a community slug. async fn attach_repo_to_project(h: &TestHarness, project_slug: &str) -> uuid::Uuid { let user_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM users WHERE username = 'testowner'", ) .fetch_one(&h.db) .await .unwrap(); let project_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO projects (user_id, title, slug) VALUES ($1, 'Test Project', $2) RETURNING id", ) .bind(user_id) .bind(project_slug) .fetch_one(&h.db) .await .unwrap(); sqlx::query("UPDATE git_repos SET project_id = $1 WHERE name = 'testrepo'") .bind(project_id) .execute(&h.db) .await .unwrap(); project_id } #[tokio::test] async fn inbound_new_issue_bridges_to_mt() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mock = MockServer::start().await; let mt_thread_id = uuid::Uuid::new_v4(); Mock::given(method("POST")) .and(path("/internal/threads")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "thread_id": mt_thread_id, "post_id": uuid::Uuid::new_v4(), "created": true, }))) .expect(1) .mount(&mock) .await; let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await; attach_repo_to_project(&h, "test-project").await; h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload( "testowner+testrepo@issues.makenot.work", "testowner@example.com", "Bridge me", "Body text", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200, "inbound: {}", resp.text); let stored: Option = sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1") .fetch_one(&h.db) .await .unwrap(); assert_eq!(stored, Some(mt_thread_id), "mt_thread_id should be cached on the issue"); } #[tokio::test] async fn inbound_new_issue_without_project_skips_bridge() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mock = MockServer::start().await; // No mount — any call would fail with 404 from wiremock. We assert that // the issue creation still succeeds and no MT call is made. let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await; // Deliberately do NOT call attach_repo_to_project — repo has no project_id. h.client.set_bearer_token("test-inbound-secret"); let payload = inbound_payload( "testowner+testrepo@issues.makenot.work", "testowner@example.com", "Orphan issue", "Body", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues") .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "issue should still be created without a project"); let stored: Option = sqlx::query_scalar("SELECT mt_thread_id FROM issues LIMIT 1") .fetch_one(&h.db) .await .unwrap(); assert!(stored.is_none(), "mt_thread_id should remain unset"); } #[tokio::test] async fn inbound_issue_reply_bridges_to_mt_thread() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mock = MockServer::start().await; let mt_thread_id = uuid::Uuid::new_v4(); // Initial issue creation Mock::given(method("POST")) .and(path("/internal/threads")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "thread_id": mt_thread_id, "post_id": uuid::Uuid::new_v4(), "created": true, }))) .mount(&mock) .await; // Reply post Mock::given(method("POST")) .and(path(format!("/internal/threads/{}/posts", mt_thread_id))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "post_id": uuid::Uuid::new_v4(), }))) .expect(1) .mount(&mock) .await; let mut h = setup_with_inbound_and_mt(&tmp, mock.uri()).await; attach_repo_to_project(&h, "test-project").await; h.client.set_bearer_token("test-inbound-secret"); // Open the issue let payload = inbound_payload( "testowner+testrepo@issues.makenot.work", "testowner@example.com", "Reply-bridged", "Initial body", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200); // Generate reply address for the new issue let issue_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM issues LIMIT 1") .fetch_one(&h.db) .await .unwrap(); let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = 'testowner'") .fetch_one(&h.db) .await .unwrap(); let reply_addr = makenotwork::email::generate_issue_reply_address( makenotwork::db::IssueId::from_uuid(issue_id), makenotwork::db::UserId::from_uuid(user_id), "test-signing-secret-for-integration-tests", ); // Send the reply let payload = inbound_payload( &reply_addr, "testowner@example.com", "Re: Reply-bridged", "Following up on this", ); let resp = h.client.post_json("/postmark/inbound-issues", &payload).await; assert_eq!(resp.status, 200); }