// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Handler tests via `tower::ServiceExt::oneshot` — no network, no external //! services. They assert the shell renders, static assets and themes serve, //! CSRF is enforced on state-changing routes, and sign-in opens a session. #![allow(clippy::unwrap_used, clippy::expect_used)] use std::sync::Arc; use axum::Router; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use config::Config; use store::{NewRepo, NewUser, Store}; use tower::ServiceExt as _; use crate::AppState; /// Build an app over an in-memory store with one admin-less user `ada` whose /// password is `secret12`. async fn app() -> (AppState, Router) { let store = Store::connect("sqlite::memory:", 1).await.unwrap(); store.migrate().await.unwrap(); let hash = auth::hash_password("secret12", auth::HashParams::default()).unwrap(); store .create_user(NewUser { username: "ada".to_string(), email: "ada@example.com".to_string(), display_name: Some("Ada".to_string()), password_hash: Some(hash), is_admin: false, must_change_password: false, }) .await .unwrap(); let mut config = Config::default(); config.instance.name = "Forge".to_string(); // Not HTTPS in tests, so don't demand Secure cookies. config.auth.cookie_secure = false; let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); let router = crate::build_router(state.clone()); (state, router) } async fn body_string(response: axum::response::Response) -> String { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); String::from_utf8_lossy(&bytes).into_owned() } fn get(uri: &str) -> Request { Request::builder().uri(uri).body(Body::empty()).unwrap() } #[tokio::test] async fn admin_dashboard_gating_and_settings_override() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // Non-admin: the admin area does not acknowledge itself (404). let req = Request::builder() .uri("/admin") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); assert_eq!( app.clone().oneshot(req).await.unwrap().status(), StatusCode::NOT_FOUND ); // Promote and retry (the session re-reads the user each request). state.store.set_admin(&ada.id, true).await.unwrap(); let req = Request::builder() .uri("/admin") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); assert!(body_string(res).await.contains("Overview")); // Override the instance name via the settings tab. let req = Request::builder() .method("POST") .uri("/admin/settings") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!( "name=Renamed&description=&default_visibility=private&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!(state.instance_name(), "Renamed", "override took effect"); } #[tokio::test] async fn home_renders_the_shell() { let (_state, app) = app().await; let res = app.oneshot(get("/")).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("")); assert!(html.contains("Forge"), "instance name should appear"); assert!(html.contains("/assets/base.css")); assert!(html.contains("/assets/htmx.min.js")); } #[tokio::test] async fn assets_and_themes_serve() { let (_state, app) = app().await; let css = app.clone().oneshot(get("/assets/base.css")).await.unwrap(); assert_eq!(css.status(), StatusCode::OK); assert_eq!( css.headers().get(header::CONTENT_TYPE).unwrap(), "text/css; charset=utf-8" ); let theme = app.clone().oneshot(get("/theme/dark.css")).await.unwrap(); assert_eq!(theme.status(), StatusCode::OK); let body = body_string(theme).await; assert!(body.contains("--fb-bg")); let default = app.clone().oneshot(get("/theme.css")).await.unwrap(); assert_eq!(default.status(), StatusCode::OK); let traversal = app.oneshot(get("/assets/../Cargo.toml")).await.unwrap(); assert_ne!(traversal.status(), StatusCode::OK); } #[tokio::test] async fn healthz_and_version_report() { let (_state, app) = app().await; let health = app.clone().oneshot(get("/healthz")).await.unwrap(); // git may or may not be present in the test env; either way it is valid JSON. let body = body_string(health).await; assert!(body.contains("\"database\":\"ok\"")); let version = app.oneshot(get("/version")).await.unwrap(); assert_eq!(version.status(), StatusCode::OK); assert!(body_string(version).await.contains("\"name\":\"Forge\"")); } #[tokio::test] async fn login_form_renders_with_a_csrf_token() { let (_state, app) = app().await; let res = app.oneshot(get("/login")).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); // The CSRF cookie is set, and its token is embedded in the form. let set_cookie = res .headers() .get(header::SET_COOKIE) .map(|v| v.to_str().unwrap().to_string()) .unwrap_or_default(); assert!( set_cookie.contains("fabrica_csrf="), "csrf cookie: {set_cookie:?}" ); let html = body_string(res).await; assert!(html.contains("name=\"_csrf\"")); } #[tokio::test] async fn post_without_csrf_is_forbidden() { let (_state, app) = app().await; let req = Request::builder() .method("POST") .uri("/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("username=ada&password=secret12&_csrf=nope")) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn login_with_matching_csrf_opens_a_session() { let (_state, app) = app().await; // First GET /login to obtain a CSRF cookie + token. let form = app.clone().oneshot(get("/login")).await.unwrap(); let cookie = form .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .to_string(); let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // POST with the cookie echoed and the token in the body → double-submit passes. let req = Request::builder() .method("POST") .uri("/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("fabrica_csrf={token}")) .body(Body::from(format!( "username=ada&password=secret12&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER, "redirect on success"); let set = res .headers() .get_all(header::SET_COOKIE) .iter() .map(|v| v.to_str().unwrap().to_string()) .collect::>() .join("\n"); assert!( set.contains("fabrica_session="), "session cookie set: {set:?}" ); } #[tokio::test] async fn bad_password_re_renders_unauthorized() { let (_state, app) = app().await; let form = app.clone().oneshot(get("/login")).await.unwrap(); let cookie = form .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .to_string(); let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let req = Request::builder() .method("POST") .uri("/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("fabrica_csrf={token}")) .body(Body::from(format!( "username=ada&password=wrong&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn invite_with_unknown_token_is_not_found() { let (_state, app) = app().await; let res = app.oneshot(get("/invite/does-not-exist")).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn settings_redirects_when_signed_out() { let (_state, app) = app().await; let res = app.oneshot(get("/settings")).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login"); } #[tokio::test] async fn unknown_theme_is_rejected() { let (_state, app) = app().await; let res = app.oneshot(get("/settings/theme?name=nope")).await.unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn private_repo_is_404_for_anonymous() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "secret".to_string(), path: "secret".to_string(), description: None, visibility: model::Visibility::Private, default_branch: "main".to_string(), }) .await .unwrap(); // Access is denied before any git read, so existence never leaks: 404, not 403. let res = app.oneshot(get("/ada/secret")).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn unknown_owner_is_404() { let (_state, app) = app().await; let res = app.oneshot(get("/nobody")).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn profile_page_renders_with_identity() { let (_state, app) = app().await; let res = app.oneshot(get("/ada")).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; // Display name in the heading and the "@username" handle line. assert!(html.contains("Ada"), "display name shown"); assert!(html.contains("@ada"), "profile handle shown"); assert!(html.contains("/avatar/ada"), "avatar image referenced"); assert!(html.contains("Joined on"), "join date shown"); } #[tokio::test] async fn avatar_falls_back_to_identicon_svg() { let (_state, app) = app().await; let res = app.oneshot(get("/avatar/ada")).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); assert_eq!( res.headers().get(header::CONTENT_TYPE).unwrap(), "image/svg+xml; charset=utf-8" ); let body = body_string(res).await; assert!(body.starts_with(" String { let form = app.clone().oneshot(get("/login")).await.unwrap(); let csrf = form .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .to_string(); let token = csrf .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let req = Request::builder() .method("POST") .uri("/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("fabrica_csrf={token}")) .body(Body::from(format!( "username=ada&password=secret12&_csrf={token}" ))) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); let session = res .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap(); let session = session.split(';').next().unwrap(); format!("fabrica_csrf={token}; {session}") } #[tokio::test] async fn dashboard_shows_activity_and_repos_when_signed_in() { let (_state, app) = app().await; let cookie = login(&app).await; let req = Request::builder() .uri("/") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!( html.contains("contributions in the last year"), "activity heatmap caption present" ); assert!(html.contains("Recent activity"), "activity feed present"); assert!(html.contains("class=\"heatmap\""), "heatmap svg present"); } #[tokio::test] async fn repo_settings_are_owner_only() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Private, default_branch: "main".to_string(), }) .await .unwrap(); // Anonymous visitor: a private repo's settings are a 404, not a 403. let res = app .clone() .oneshot(get("/ada/proj/-/settings")) .await .unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); // The owner sees the settings page with the delete form. let cookie = login(&app).await; let req = Request::builder() .uri("/ada/proj/-/settings") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("Danger zone"), "danger zone shown"); assert!( html.contains(&format!("/repo-settings/{}/delete", repo.id)), "delete action targets the repo id" ); } #[tokio::test] async fn new_repo_page_requires_auth_and_renders() { let (_state, app) = app().await; // Anonymous → redirected to sign in. let res = app.clone().oneshot(get("/new")).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login"); // Signed in → the creation form. let cookie = login(&app).await; let req = Request::builder() .uri("/new") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("New repository")); assert!(html.contains("name=\"visibility\"")); } #[tokio::test] async fn settings_tabs_render() { let (_state, app) = app().await; let cookie = login(&app).await; let get_tab = |uri: &'static str, cookie: String| { let app = app.clone(); async move { let req = Request::builder() .uri(uri) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK, "{uri}"); body_string(res).await } }; // The Profile tab shows the tab nav. let profile = get_tab("/settings", cookie.clone()).await; assert!(profile.contains("Save profile"), "profile tab"); assert!(profile.contains("/settings/account"), "tab nav present"); // The Account tab shows username, emails, and password. let account = get_tab("/settings/account", cookie.clone()).await; assert!(account.contains("Change username"), "username"); assert!(account.contains("Emails"), "emails section"); assert!(account.contains("Change password"), "password"); // The tokens tab. let tokens = get_tab("/settings/tokens", cookie).await; assert!(tokens.contains("API tokens"), "tokens tab"); } #[tokio::test] async fn emails_add_set_primary_and_publish_on_profile() { let (state, app) = app().await; let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); // Add a second address. let post = |uri: &'static str, body: String, cookie: String| { let app = app.clone(); async move { let req = Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(body)) .unwrap(); app.oneshot(req).await.unwrap() } }; let res = post( "/settings/emails", format!("email=second@example.com&_csrf={token}"), cookie.clone(), ) .await; assert_eq!(res.status(), StatusCode::SEE_OTHER); let emails = state.store.emails_by_user(&ada.id).await.unwrap(); assert_eq!(emails.len(), 2); let second = emails .iter() .find(|e| e.email == "second@example.com") .unwrap(); assert!(!second.is_primary); assert!(!second.verified(), "added emails start unverified"); let second_id = second.id.clone(); // A new address must be verified before it can become primary. let res = post( "/settings/emails/primary", format!("id={second_id}&_csrf={token}"), cookie.clone(), ) .await; assert_eq!( res.status(), StatusCode::BAD_REQUEST, "unverified cannot be primary" ); // Exercise the verification flow: token issued, then redeemed. state .store .begin_email_verification(&second_id, "verify-token") .await .unwrap(); assert!( state .store .verify_email_token("verify-token") .await .unwrap() .is_some() ); // Now it can be made primary; users.email mirrors it. let res = post( "/settings/emails/primary", format!("id={second_id}&_csrf={token}"), cookie.clone(), ) .await; assert_eq!(res.status(), StatusCode::SEE_OTHER); let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); assert_eq!(ada.email, "second@example.com"); // Publish it and confirm it appears on the profile. let res = post( "/settings/emails/visibility", format!("public=1&_csrf={token}"), cookie, ) .await; assert_eq!(res.status(), StatusCode::SEE_OTHER); let res = app.oneshot(get("/ada")).await.unwrap(); let html = body_string(res).await; assert!( html.contains("second@example.com"), "email shown on profile" ); } /// An app with self-registration enabled (and no seeded user). async fn app_with_registration() -> Router { let store = Store::connect("sqlite::memory:", 1).await.unwrap(); store.migrate().await.unwrap(); let mut config = Config::default(); config.instance.name = "Forge".to_string(); config.instance.allow_registration = true; config.auth.cookie_secure = false; let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); crate::build_router(state) } #[tokio::test] async fn registration_is_404_when_disabled() { let (_state, app) = app().await; let res = app.oneshot(get("/register")).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn registration_creates_an_account_and_signs_in() { let app = app_with_registration().await; let form = app.clone().oneshot(get("/register")).await.unwrap(); assert_eq!(form.status(), StatusCode::OK); let cookie = form .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .to_string(); let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); assert!(body_string(form).await.contains("Create account")); let req = Request::builder() .method("POST") .uri("/register") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("fabrica_csrf={token}")) .body(Body::from(format!( "username=newbie&email=new@example.com&password=secret12&confirm=secret12&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER, "redirect on success"); let set = res .headers() .get_all(header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .collect::>() .join("\n"); assert!(set.contains("fabrica_session="), "signed in: {set:?}"); } #[tokio::test] async fn issues_open_create_and_view() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Private, default_branch: "main".to_string(), }) .await .unwrap(); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // The issues list renders for the owner with a New button. let req = Request::builder() .uri("/ada/proj/-/issues") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); assert!(body_string(res).await.contains("New issue")); // Create an issue. let req = Request::builder() .method("POST") .uri(format!("/issue-new/{}", repo.id)) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie.clone()) .body(Body::from(format!( "title=First+bug&body=It+is+broken&_csrf={token}" ))) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!( res.headers().get(header::LOCATION).unwrap(), "/ada/proj/-/issues/1" ); // View it. let req = Request::builder() .uri("/ada/proj/-/issues/1") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("First bug"), "issue title shown"); assert!(html.contains("#1"), "issue number shown"); // Commenting works (regression: nested state form used to duplicate _csrf). let issue = state .store .issue_by_number(&repo.id, 1, false) .await .unwrap() .unwrap(); let req = Request::builder() .method("POST") .uri(format!("/issue/{}/comment", issue.id)) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!("body=a+comment&_csrf={token}"))) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER, "comment accepted"); assert_eq!(state.store.list_comments(&issue.id).await.unwrap().len(), 1); } #[tokio::test] async fn issue_title_and_comments_are_editable() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id.clone(), group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); let issue = state .store .create_issue(&repo.id, &ada.id, "typo", "body", false) .await .unwrap(); let comment = state .store .add_comment(&issue.id, &ada.id, "orignal") .await .unwrap(); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let post = |uri: String, body: String, cookie: String| { let app = app.clone(); async move { let req = Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(body)) .unwrap(); app.oneshot(req).await.unwrap() } }; // Edit the issue title (title only; body preserved). let res = post( format!("/issue/{}/edit-title", issue.id), format!("title=Fixed+title&_csrf={token}"), cookie.clone(), ) .await; assert_eq!(res.status(), StatusCode::SEE_OTHER); let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap(); assert_eq!(updated.title, "Fixed title"); assert_eq!(updated.body, "body", "body untouched by title edit"); // Edit the issue description via its own card. let res = post( format!("/issue/{}/edit-body", issue.id), format!("body=new+body&_csrf={token}"), cookie.clone(), ) .await; assert_eq!(res.status(), StatusCode::SEE_OTHER); let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap(); assert_eq!(updated.title, "Fixed title", "title untouched by body edit"); assert_eq!(updated.body, "new body"); // Edit the comment. let res = post( format!("/comment/{}/edit", comment.id), format!("body=corrected&_csrf={token}"), cookie, ) .await; assert_eq!(res.status(), StatusCode::SEE_OTHER); let edited = state .store .comment_by_id(&comment.id) .await .unwrap() .unwrap(); assert_eq!(edited.body, "corrected"); } #[tokio::test] async fn issue_can_be_locked_and_unlocked() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id.clone(), group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); let issue = state .store .create_issue(&repo.id, &ada.id, "hot", "", false) .await .unwrap(); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let lock = |locked: bool, cookie: String| { let app = app.clone(); let token = token.clone(); let id = issue.id.clone(); async move { let req = Request::builder() .method("POST") .uri(format!("/issue/{id}/lock")) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!("locked={locked}&_csrf={token}"))) .unwrap(); app.oneshot(req).await.unwrap() } }; let res = lock(true, cookie.clone()).await; assert_eq!(res.status(), StatusCode::SEE_OTHER); assert!( state .store .issue_by_id(&issue.id) .await .unwrap() .unwrap() .locked() ); let res = lock(false, cookie).await; assert_eq!(res.status(), StatusCode::SEE_OTHER); assert!( !state .store .issue_by_id(&issue.id) .await .unwrap() .unwrap() .locked() ); } #[tokio::test] async fn new_issue_can_apply_labels() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id.clone(), group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); let bug = state .store .create_label(&repo.id, "bug", "#ff0000", None) .await .unwrap(); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let req = Request::builder() .method("POST") .uri(format!("/issue-new/{}", repo.id)) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!( "title=Tagged&body=&label={}&_csrf={token}", bug.id ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); let issue = state .store .issue_by_number(&repo.id, 1, false) .await .unwrap() .unwrap(); let labels = state.store.issue_labels(&issue.id).await.unwrap(); assert_eq!(labels.len(), 1, "label applied on create"); assert_eq!(labels[0].name, "bug"); } #[tokio::test] async fn issue_list_paginates() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id.clone(), group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); // Three open issues. for i in 0..3 { state .store .create_issue(&repo.id, &ada.id, &format!("bug {i}"), "", false) .await .unwrap(); } // First page of two shows a Next control. let res = app .clone() .oneshot(get("/ada/proj/-/issues?per_page=2")) .await .unwrap(); let html = body_string(res).await; assert!(html.contains("class=\"pagination\""), "pagination shown"); assert!(html.contains("page=2"), "next link present"); assert_eq!(html.matches("entry-row-title").count(), 2, "two per page"); // Second page holds the remaining one. let res = app .oneshot(get("/ada/proj/-/issues?per_page=2&page=2")) .await .unwrap(); let html = body_string(res).await; assert_eq!(html.matches("entry-row-title").count(), 1, "one on page 2"); } #[tokio::test] async fn issue_dependencies_block_and_unblock() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id.clone(), group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); // #1 will depend on #2. let one = state .store .create_issue(&repo.id, &ada.id, "feature", "", false) .await .unwrap(); let two = state .store .create_issue(&repo.id, &ada.id, "prerequisite", "", false) .await .unwrap(); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // Add the dependency: #1 depends on #2. let req = Request::builder() .method("POST") .uri(format!("/issue/{}/deps/add", one.id)) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie.clone()) .body(Body::from(format!("number=2&_csrf={token}"))) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!( state.store.open_dependency_count(&one.id).await.unwrap(), 1, "one open blocker" ); // #1's view lists the blocker; #2's view lists what it blocks. let res = app .clone() .oneshot(get("/ada/proj/-/issues/1")) .await .unwrap(); let html = body_string(res).await; assert!(html.contains("Blocked by"), "blocked-by section"); assert!(html.contains("prerequisite"), "blocker listed"); // Closing #2 clears the block. state .store .set_issue_state(&two.id, model::IssueState::Closed) .await .unwrap(); assert_eq!( state.store.open_dependency_count(&one.id).await.unwrap(), 0, "no open blockers once closed" ); } #[tokio::test] async fn issues_404_when_disabled() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "noissues".to_string(), path: "noissues".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); state .store .set_feature_enabled(&repo.id, "issues", false) .await .unwrap(); let res = app.oneshot(get("/ada/noissues/-/issues")).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } /// Build an app whose storage lives under a temp dir, so real bare repositories /// can be created on disk — needed for pull-request diffs and merges. The temp /// dir is returned so it outlives the test. async fn app_with_git() -> (AppState, Router, tempfile::TempDir) { let store = Store::connect("sqlite::memory:", 1).await.unwrap(); store.migrate().await.unwrap(); let hash = auth::hash_password("secret12", auth::HashParams::default()).unwrap(); store .create_user(NewUser { username: "ada".to_string(), email: "ada@example.com".to_string(), display_name: Some("Ada".to_string()), password_hash: Some(hash), is_admin: false, must_change_password: false, }) .await .unwrap(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.instance.name = "Forge".to_string(); config.auth.cookie_secure = false; config.storage.repo_dir = dir.path().join("repos"); config.storage.data_dir = dir.path().to_path_buf(); std::fs::create_dir_all(config.storage.data_dir.join("tmp")).unwrap(); let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); let router = crate::build_router(state.clone()); (state, router, dir) } /// Commit `files` onto `branch` in a bare repo via libgit2, returning the new oid. fn seed_commit( raw: &git2::Repository, branch: &str, parents: &[git2::Oid], files: &[(&str, &str)], message: &str, t: i64, ) -> git2::Oid { let mut root = raw.treebuilder(None).unwrap(); for (name, content) in files { let blob = raw.blob(content.as_bytes()).unwrap(); root.insert(name, blob, 0o100_644).unwrap(); } let tree = raw.find_tree(root.write().unwrap()).unwrap(); let sig = git2::Signature::new("Ada", "ada@example.com", &git2::Time::new(t, 0)).unwrap(); let parent_commits: Vec<_> = parents .iter() .map(|p| raw.find_commit(*p).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); raw.commit( Some(&format!("refs/heads/{branch}")), &sig, &sig, message, &tree, &parent_refs, ) .unwrap() } #[tokio::test] async fn pull_request_create_view_and_merge() { // Merge shells out to `git`; skip cleanly where it is absent. if std::process::Command::new("git") .arg("--version") .output() .is_err() { return; } let (state, app, _dir) = app_with_git().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); // Create the bare repo on disk with a mergeable feature branch. let hook = state.config.storage.data_dir.join("fabrica"); git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap(); let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap(); let raw = git2::Repository::open_bare(&path).unwrap(); let base = seed_commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1); seed_commit( &raw, "feature", &[base], &[("README.md", "hello\n"), ("feature.txt", "new\n")], "add feature", 2, ); let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // Open a pull request comparing feature → main. let req = Request::builder() .method("POST") .uri(format!("/pull-new/{}", repo.id)) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie.clone()) .body(Body::from(format!( "base_ref=main&head_ref=feature&title=Add+feature&body=please&_csrf={token}" ))) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!( res.headers().get(header::LOCATION).unwrap(), "/ada/proj/-/pulls/1" ); // The PR page shows the compare summary, the diff, and a clean merge panel. let req = Request::builder() .uri("/ada/proj/-/pulls/1") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("wants to merge"), "compare summary shown"); assert!(html.contains("feature.txt"), "diff lists the new file"); assert!(html.contains("Merge pull request"), "merge button shown"); // Merge it (default merge-commit strategy). let issue = state .store .issue_by_number(&repo.id, 1, true) .await .unwrap() .unwrap(); let req = Request::builder() .method("POST") .uri(format!("/pull/{}/merge", issue.id)) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!("strategy=merge&_csrf={token}"))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); // The PR is now merged and closed, and main carries the feature file. let pr = state.store.pull_by_issue(&issue.id).await.unwrap().unwrap(); assert!(pr.merged_at.is_some(), "merge recorded"); assert!(pr.merge_sha.is_some(), "merge sha stored"); let closed = state.store.issue_by_id(&issue.id).await.unwrap().unwrap(); assert_eq!(closed.state, model::IssueState::Closed, "PR auto-closed"); let main_tip = raw .find_reference("refs/heads/main") .unwrap() .peel_to_commit() .unwrap(); assert!( main_tip.tree().unwrap().get_name("feature.txt").is_some(), "main now contains the merged file" ); assert_eq!(main_tip.parent_count(), 2, "a merge commit"); } #[tokio::test] async fn new_repo_can_use_sha256_object_format() { if std::process::Command::new("git") .arg("--version") .output() .is_err() { return; } let (state, app, _dir) = app_with_git().await; let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let req = Request::builder() .method("POST") .uri("/new") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!( "name=modern&visibility=private&default_branch=main&object_format=sha256&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .repo_by_owner_path(&ada.id, "modern") .await .unwrap() .unwrap(); assert_eq!(repo.object_format, "sha256", "row records the format"); // The on-disk repo is really SHA-256. let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap(); let out = std::process::Command::new("git") .arg("--git-dir") .arg(&path) .args(["rev-parse", "--show-object-format"]) .output() .unwrap(); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256"); } #[tokio::test] async fn repo_home_shows_a_language_bar() { let (state, app, _dir) = app_with_git().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); let hook = state.config.storage.data_dir.join("fabrica"); git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap(); let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap(); let raw = git2::Repository::open_bare(&path).unwrap(); seed_commit( &raw, "main", &[], &[ ("main.rs", "fn main() { println!(\"hi\"); }\n"), ("util.py", "print('hi')\n"), ], "init", 1, ); let res = app.oneshot(get("/ada/proj")).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("lang-bar"), "language bar rendered"); assert!(html.contains("Rust"), "Rust detected"); assert!(html.contains("Python"), "Python detected"); } #[tokio::test] async fn license_file_shows_a_rights_banner() { let (state, app, _dir) = app_with_git().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); let hook = state.config.storage.data_dir.join("fabrica"); git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap(); let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap(); let raw = git2::Repository::open_bare(&path).unwrap(); let mit = "MIT License\n\nPermission is hereby granted, free of charge, to any person \ obtaining a copy of this software.\n"; seed_commit(&raw, "main", &[], &[("LICENSE", mit)], "add license", 1); let res = app .oneshot(get("/ada/proj/-/blob/main/LICENSE")) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("MIT License"), "license name shown"); assert!(html.contains("Permissions"), "rights columns shown"); assert!(html.contains("license-banner"), "banner rendered"); } #[tokio::test] async fn pulls_404_when_disabled() { let (state, app) = app().await; let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let repo = state .store .create_repo(NewRepo { owner_id: ada.id, group_id: None, name: "nopr".to_string(), path: "nopr".to_string(), description: None, visibility: model::Visibility::Public, default_branch: "main".to_string(), }) .await .unwrap(); state .store .set_feature_enabled(&repo.id, "pulls", false) .await .unwrap(); let res = app.oneshot(get("/ada/nopr/-/pulls")).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn settings_requires_authentication() { let (_state, app) = app().await; let res = app.oneshot(get("/settings")).await.unwrap(); // RequireUser redirects anonymous visitors to /login. assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login"); } /// A valid SSH public key for exercising the key-management UI. const TEST_SSH_KEY: &str = "ssh-ed25519 \ AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU web-test@fabrica"; #[tokio::test] async fn settings_keys_add_list_and_delete() { let (state, app) = app().await; let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // The settings page shows the keys section. let req = Request::builder() .uri("/settings/keys") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); assert!(body_string(res).await.contains("SSH and GPG keys")); // Add an SSH key. let body = format!( "kind=ssh&name=laptop&key={}&_csrf={token}", urlencoding(TEST_SSH_KEY) ); let req = Request::builder() .method("POST") .uri("/settings/keys") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie.clone()) .body(Body::from(body)) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); let keys = state.store.keys_by_user(&ada.id).await.unwrap(); assert_eq!(keys.len(), 1, "key was stored"); let key_id = keys[0].id.clone(); // It renders on the settings page. let req = Request::builder() .uri("/settings/keys") .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap(); let res = app.clone().oneshot(req).await.unwrap(); let listing = body_string(res).await; assert!(listing.contains("laptop"), "key label shown"); assert!(listing.contains("unverified"), "new keys start unverified"); assert!( listing.contains("authenticate over SSH"), "SSH auto-verify hint shown" ); // Delete it. let req = Request::builder() .method("POST") .uri("/settings/keys/delete") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!("id={key_id}&_csrf={token}"))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert!(state.store.keys_by_user(&ada.id).await.unwrap().is_empty()); } #[tokio::test] async fn settings_key_add_rejects_garbage() { let (_state, app) = app().await; let cookie = login(&app).await; let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); let req = Request::builder() .method("POST") .uri("/settings/keys") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, cookie) .body(Body::from(format!( "kind=ssh&name=x&key=not-a-key&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); } /// An app with hCaptcha configured, so the sign-up form must show and enforce it. async fn app_with_captcha() -> Router { let store = Store::connect("sqlite::memory:", 1).await.unwrap(); store.migrate().await.unwrap(); let mut config = Config::default(); config.instance.name = "Forge".to_string(); config.instance.allow_registration = true; config.auth.cookie_secure = false; config.captcha.provider = config::CaptchaProvider::HCaptcha; config.captcha.site_key = "test-site-key".to_string(); config.captcha.secret_key = Some(config::Secret::new("test-secret".to_string())); let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); crate::build_router(state) } #[tokio::test] async fn register_page_shows_the_captcha_widget() { let app = app_with_captcha().await; let res = app.oneshot(get("/register")).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let html = body_string(res).await; assert!(html.contains("js.hcaptcha.com"), "widget script present"); assert!( html.contains("data-sitekey=\"test-site-key\""), "site key present" ); assert!(html.contains("h-captcha"), "widget container present"); } #[tokio::test] async fn register_is_rejected_when_captcha_is_missing() { let app = app_with_captcha().await; let form = app.clone().oneshot(get("/register")).await.unwrap(); let cookie = form .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .to_string(); let token = cookie .split("fabrica_csrf=") .nth(1) .and_then(|s| s.split(';').next()) .unwrap() .to_string(); // No captcha token in the body → rejected before any network call. let req = Request::builder() .method("POST") .uri("/register") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("fabrica_csrf={token}")) .body(Body::from(format!( "username=bot&email=bot@example.com&password=secret12&confirm=secret12&_csrf={token}" ))) .unwrap(); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert!( body_string(res) .await .contains("Captcha verification failed") ); } /// Minimal application/x-www-form-urlencoded encoding for test bodies. fn urlencoding(s: &str) -> String { use std::fmt::Write as _; let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } b' ' => out.push('+'), _ => { let _ = write!(out, "%{b:02X}"); } } } out }