// 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
}