refactor(tests): introduce shared tests/common/ support module

Lift duplicated test-harness code (User factories, TestConfig builder,
login/CSRF flow, form/json helpers, case-directory seeding, URL path
builders) into `server/tests/common/` so future API changes touch one
file instead of every integration test.

Migrated batches: csrf_attack, auth, login, magic_link, security_headers
(28 tests); analyze, case_page, delete_recording, close_watermark,
retention_sweep (67 tests). All 95 tests still green.

Net line delta across these 10 files: +1113 / -1896 (~780 lines removed),
plus ~500 lines of new shared infrastructure under tests/common/.
This commit is contained in:
2026-04-22 10:42:51 +02:00
parent 2e3b5efe86
commit f438e972d4
17 changed files with 1808 additions and 1898 deletions
+71 -185
View File
@@ -7,89 +7,16 @@
//! the `CsrfForm<T>` extractor in `server/src/csrf.rs`, validated
//! against `WebSession::csrf_token`.
use std::collections::HashMap;
use std::sync::Arc;
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
// ---------- fixtures ----------
fn make_user(slug: &str, password: &str, role: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
web_password: bcrypt::hash(password, 4).unwrap(),
role: role.into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
preview_lines: 2,
}
}
fn test_config_with(users: Vec<User>) -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
"doctate-csrf-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
Arc::new(Config {
data_path,
users,
api_keys,
..Config::test_default()
})
}
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
let s = v.to_str().ok()?;
if let Some(pair) = s.split(';').next()
&& pair.starts_with("session=")
{
return Some(pair.to_string());
}
}
None
}
fn login_request(slug: &str, password: &str) -> Request<Body> {
let body = format!("slug={slug}&password={password}");
Request::builder()
.method("POST")
.uri("/web/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
async fn login_as(app: &axum::Router, slug: &str, password: &str) -> String {
let resp = app
.clone()
.oneshot(login_request(slug, password))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
extract_session_cookie(&resp).expect("no session cookie after login")
}
fn form_post(path: &str, cookie: &str, body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(path)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.body(Body::from(body.to_owned()))
.unwrap()
}
use common::{
TestConfig, csrf_form_post, form_post, get_with_cookie, login, login_with_csrf, paths,
seed_case, test_admin, test_user, test_user_with_password,
};
// ---------- session hygiene (green today) ----------
@@ -99,7 +26,9 @@ fn form_post(path: &str, cookie: &str, body: &str) -> Request<Body> {
/// login mints a fresh token; the pre-set value must be overwritten.
#[tokio::test]
async fn session_fixation_login_rotates_cookie() {
let cfg = test_config_with(vec![make_user("dr_a", "secret", "doctor")]);
let cfg = TestConfig::new()
.with_user(test_user_with_password("dr_a", "secret"))
.build();
let app = doctate_server::create_router(cfg);
let attacker_fixed_value = "session=attacker-fixed-token-value-aaaaaaaaaaaaaaaa";
@@ -107,7 +36,7 @@ async fn session_fixation_login_rotates_cookie() {
.oneshot(
Request::builder()
.method("POST")
.uri("/web/login")
.uri(paths::LOGIN)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, attacker_fixed_value)
.body(Body::from("slug=dr_a&password=secret"))
@@ -117,7 +46,7 @@ async fn session_fixation_login_rotates_cookie() {
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
let minted = extract_session_cookie(&resp).expect("no Set-Cookie on login");
let minted = common::extract_session_cookie(&resp).expect("no Set-Cookie on login");
assert!(
minted != attacker_fixed_value,
"login did not rotate the cookie — session fixation possible"
@@ -126,7 +55,6 @@ async fn session_fixation_login_rotates_cookie() {
minted.starts_with("session="),
"Set-Cookie shape unexpected: {minted}"
);
// Token portion has the full 43-char entropy, not the attacker's value.
let minted_token = minted.trim_start_matches("session=");
assert!(
minted_token.len() >= 40,
@@ -138,13 +66,13 @@ async fn session_fixation_login_rotates_cookie() {
#[tokio::test]
async fn bulk_without_csrf_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.oneshot(form_post(paths::BULK, &cookie, body))
.await
.unwrap();
assert_eq!(
@@ -157,12 +85,12 @@ async fn bulk_without_csrf_token_forbidden() {
#[tokio::test]
async fn purge_closed_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
let resp = app
.oneshot(form_post("/web/cases/purge-closed", &cookie, "confirm=yes"))
.oneshot(form_post(paths::PURGE_CLOSED, &cookie, "confirm=yes"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
@@ -170,13 +98,13 @@ async fn purge_closed_without_csrf_forbidden() {
#[tokio::test]
async fn reset_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/reset",
paths::case_reset("11111111-1111-1111-1111-111111111111"),
&cookie,
"",
))
@@ -187,13 +115,13 @@ async fn reset_without_csrf_forbidden() {
#[tokio::test]
async fn close_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let cookie = login(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/close",
paths::case_close("11111111-1111-1111-1111-111111111111"),
&cookie,
"",
))
@@ -204,13 +132,13 @@ async fn close_without_csrf_forbidden() {
#[tokio::test]
async fn reopen_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let cookie = login(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/reopen",
paths::case_reopen("11111111-1111-1111-1111-111111111111"),
&cookie,
"",
))
@@ -221,13 +149,13 @@ async fn reopen_without_csrf_forbidden() {
#[tokio::test]
async fn analyze_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let cookie = login(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/analyze",
paths::case_analyze("11111111-1111-1111-1111-111111111111"),
&cookie,
"",
))
@@ -238,13 +166,13 @@ async fn analyze_without_csrf_forbidden() {
#[tokio::test]
async fn delete_recording_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let cookie = login(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/recordings/delete",
paths::recording_delete("11111111-1111-1111-1111-111111111111"),
&cookie,
"filename=2026-04-14T10-00-00Z.m4a",
))
@@ -258,12 +186,12 @@ async fn delete_recording_without_csrf_forbidden() {
/// victim re-enters password on a lookalike page).
#[tokio::test]
async fn logout_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let cookie = login(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post("/web/logout", &cookie, ""))
.oneshot(form_post(paths::LOGOUT, &cookie, ""))
.await
.unwrap();
assert_eq!(
@@ -277,9 +205,9 @@ async fn logout_without_csrf_forbidden() {
#[tokio::test]
async fn bulk_with_wrong_csrf_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
// A 43-char alphanumeric that is structurally valid but does not
// match the session's token.
@@ -287,7 +215,7 @@ async fn bulk_with_wrong_csrf_token_forbidden() {
let body =
format!("action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}");
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, &body))
.oneshot(form_post(paths::BULK, &cookie, body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
@@ -295,13 +223,13 @@ async fn bulk_with_wrong_csrf_token_forbidden() {
#[tokio::test]
async fn bulk_with_empty_csrf_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.oneshot(form_post(paths::BULK, &cookie, body))
.await
.unwrap();
assert_eq!(
@@ -316,15 +244,14 @@ async fn bulk_with_empty_csrf_token_forbidden() {
/// `if token.is_empty() { skip }` branch. Defense: compare raw.
#[tokio::test]
async fn bulk_with_whitespace_padded_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
// Leading+trailing spaces around a value that *would* match post-trim.
// Even if the real token were "VALID", this should still fail.
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.oneshot(form_post(paths::BULK, &cookie, body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
@@ -336,15 +263,15 @@ async fn bulk_with_whitespace_padded_token_forbidden() {
/// unusual byte content.
#[tokio::test]
async fn bulk_with_injection_payload_returns_403_not_500() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let cookie = login(&app, "dr_admin", "s").await;
// `' OR 1=1--` URL-encoded.
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000\
&csrf_token=%27+OR+1%3D1--";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.oneshot(form_post(paths::BULK, &cookie, body))
.await
.unwrap();
assert_eq!(
@@ -364,45 +291,10 @@ async fn bulk_with_injection_payload_returns_403_not_500() {
// changing form without the {% call csrf::field(csrf_token) %} macro,
// the browser flow breaks and one of these fires.
async fn login_and_get_csrf(
app: &axum::Router,
store: &doctate_server::web_session::SessionStore,
slug: &str,
password: &str,
) -> (String, String) {
let resp = app
.clone()
.oneshot(login_request(slug, password))
.await
.unwrap();
let cookie = extract_session_cookie(&resp).expect("login cookie");
let csrf = store
.read()
.await
.get(cookie.trim_start_matches("session="))
.unwrap()
.csrf_token
.clone();
(cookie, csrf)
}
async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let resp = app.clone().oneshot(get_with_cookie(uri, cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
common::body_string(resp).await
}
fn hidden_field(token: &str) -> String {
@@ -411,29 +303,28 @@ fn hidden_field(token: &str) -> String {
#[tokio::test]
async fn my_cases_page_renders_csrf_token_hidden_field() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
// Seed a case so the bulk form is rendered (the template skips it
// when the list is empty) — otherwise only the logout form shows.
let case_dir = cfg
.data_path
.join("dr_admin/11111111-1111-1111-1111-111111111111");
std::fs::create_dir_all(&case_dir).unwrap();
let case_dir = seed_case(
&cfg.data_path,
"dr_admin",
"11111111-1111-1111-1111-111111111111",
);
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_admin", "s").await;
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_admin", "s").await;
let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await;
let expected = hidden_field(&csrf);
// Logout form must carry the token.
assert!(
body.contains(&expected),
"/web/cases should render hidden csrf_token ({expected}), \
first 200 chars of body: {}",
&body[..body.len().min(200)]
);
// Logout form must specifically have it (present in the body).
assert!(
body.contains(r#"action="/web/logout""#)
&& body.matches(r#"name="csrf_token""#).count() >= 2,
@@ -445,24 +336,21 @@ async fn my_cases_page_renders_csrf_token_hidden_field() {
#[tokio::test]
async fn case_page_renders_csrf_token_hidden_field() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let case_id = "11111111-1111-1111-1111-111111111111";
// Seed a minimal case so GET /web/cases/{id} returns 200 instead of 404.
let case_dir = cfg.data_path.join("dr_a").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await;
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await;
let body = html_body(&app, &paths::case_detail(case_id), &cookie).await;
let expected = hidden_field(&csrf);
assert!(
body.contains(&expected),
"case page must render hidden csrf_token for close/analyze forms"
);
// Close form plus logout form → at least 2 hidden fields.
assert!(
body.matches(r#"name="csrf_token""#).count() >= 2,
"case page should have ≥2 csrf_token inputs; got {}",
@@ -472,24 +360,22 @@ async fn case_page_renders_csrf_token_hidden_field() {
#[tokio::test]
async fn case_recordings_page_renders_csrf_token_hidden_field() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = cfg.data_path.join("dr_a").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.transcript.txt"), b"hi").unwrap();
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await;
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let body = html_body(&app, &format!("/web/cases/{case_id}/recordings"), &cookie).await;
let body = html_body(&app, &paths::case_recordings(case_id), &cookie).await;
let expected = hidden_field(&csrf);
assert!(
body.contains(&expected),
"recordings page must render hidden csrf_token for the delete form"
);
// Logout form + one delete form per recording (one here) ≥ 2.
assert!(
body.matches(r#"name="csrf_token""#).count() >= 2,
"recordings page should have ≥2 csrf_token inputs; got {}",
@@ -505,19 +391,18 @@ async fn case_recordings_page_renders_csrf_token_hidden_field() {
/// server won't accept (e.g. accidental trim, base64-encoded, etc.).
#[tokio::test]
async fn happy_path_post_with_rendered_token_accepted() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = cfg.data_path.join("dr_a").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, session_csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await;
let (cookie, session_csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
// Render the case page, parse the csrf_token value out of any
// hidden input — do NOT use the session store directly; this tests
// the *rendered* token specifically.
let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await;
let body = html_body(&app, &paths::case_detail(case_id), &cookie).await;
let needle = r#"name="csrf_token" value=""#;
let start = body.find(needle).expect("no csrf_token in HTML") + needle.len();
let end = body[start..]
@@ -535,10 +420,11 @@ async fn happy_path_post_with_rendered_token_accepted() {
// POST close with the rendered token → must succeed.
let resp = app
.oneshot(form_post(
&format!("/web/cases/{case_id}/close"),
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&format!("csrf_token={rendered}"),
rendered,
"",
))
.await
.unwrap();