diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index d36dae9..afe7d4c 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -1,176 +1,47 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +mod common; + +use std::path::Path; use std::sync::Arc; use std::time::Duration; -use axum::body::Body; -use axum::http::{Request, StatusCode, header}; -use doctate_server::analyze; -use doctate_server::config::{Config, User}; -use serde_json::{Value, json}; +use axum::http::{StatusCode, header}; use tower::util::ServiceExt; + +use doctate_server::analyze; +use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use common::{ + TestConfig, csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, + test_admin, test_user, unique_tmpdir, +}; + // --------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------- -fn unique_tmp(label: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "doctate-close-{label}-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )) +fn cfg_llm(label: &'static str) -> std::sync::Arc { + TestConfig::new() + .with_label(label) + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build() } -fn make_user(slug: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash("s", 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } +fn cfg_llm_admin(label: &'static str) -> std::sync::Arc { + TestConfig::new() + .with_label(label) + .with_user(test_admin("dr_a")) + .with_llm("http://unused") + .build() } -fn make_admin(slug: &str) -> User { - let mut u = make_user(slug); - u.role = "admin".into(); - u -} - -fn config_with_llm(data_path: PathBuf, llm_url: String) -> Arc { - config_with_llm_users(data_path, llm_url, vec![make_user("dr_a")]) -} - -fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - llm_url, - llm_api_key: "test-key".into(), - llm_model: "test-model".into(), - ..Config::test_default() - }) -} - -fn config_without_llm(data_path: PathBuf) -> Arc { - let users = vec![make_user("dr_a")]; - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - ..Config::test_default() - }) -} - -fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { - let dir = data_path.join(slug).join(case_id); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) { - let filename = format!("2026-04-15T{ts_hms}Z.m4a"); - std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap(); - if let Some(text) = transcript { - let tx_name = format!("2026-04-15T{ts_hms}Z.transcript.txt"); - std::fs::write(case_dir.join(tx_name), text).unwrap(); - } -} - -async fn login( - app: axum::Router, - store: &doctate_server::web_session::SessionStore, - slug: &str, -) -> (String, String) { - let body = format!("slug={slug}&password=s"); - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/login") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - for v in resp.headers().get_all(header::SET_COOKIE).iter() { - let s = v.to_str().unwrap(); - if let Some(pair) = s.split(';').next() - && pair.starts_with("session=") - { - let cookie = pair.to_string(); - let csrf = store - .read() - .await - .get(cookie.trim_start_matches("session=")) - .expect("session must be in store after login") - .csrf_token - .clone(); - return (cookie, csrf); - } - } - panic!("no session cookie after login"); -} - -fn analyze_request(case_id: &str, cookie: &str, csrf: &str) -> Request { - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/analyze")) - .header(header::COOKIE, cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap() -} - -fn close_request(case_id: &str, cookie: &str, csrf: &str) -> Request { - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/close")) - .header(header::COOKIE, cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap() -} - -fn reopen_request(case_id: &str, cookie: &str, csrf: &str) -> Request { - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/reopen")) - .header(header::COOKIE, cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap() -} - -fn purge_closed_request(cookie: &str, csrf: &str, confirm: Option<&str>) -> Request { - let mut body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default(); - if !body.is_empty() { - body.push('&'); - } - body.push_str(&format!("csrf_token={csrf}")); - Request::builder() - .method("POST") - .uri("/web/cases/purge-closed") - .header(header::COOKIE, cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap() +fn cfg_without_llm(label: &'static str) -> std::sync::Arc { + TestConfig::new() + .with_label(label) + .with_user(test_user("dr_a")) + .build() } // --------------------------------------------------------------------- @@ -179,15 +50,15 @@ fn purge_closed_request(cookie: &str, csrf: &str, confirm: Option<&str>) -> Requ #[tokio::test] async fn analyze_without_cookie_redirects_to_login() { - let config = config_with_llm(unique_tmp("a"), "http://unused".into()); - let app = doctate_server::create_router(config); + let cfg = cfg_llm("a"); + let app = doctate_server::create_router(cfg); let case_id = "11111111-1111-1111-1111-111111111111"; let resp = app .oneshot( - Request::builder() + axum::http::Request::builder() .method("POST") - .uri(format!("/web/cases/{case_id}/analyze")) - .body(Body::empty()) + .uri(paths::case_analyze(case_id)) + .body(axum::body::Body::empty()) .unwrap(), ) .await @@ -197,18 +68,23 @@ async fn analyze_without_cookie_redirects_to_login() { #[tokio::test] async fn analyze_foreign_case_returns_404() { - let config = config_with_llm(unique_tmp("b"), "http://unused".into()); + let cfg = cfg_llm("b"); // Seed a case owned by someone else. let foreign_case = "22222222-2222-2222-2222-222222222222"; - let foreign_dir = config.data_path.join("dr_other").join(foreign_case); + let foreign_dir = cfg.data_path.join("dr_other").join(foreign_case); std::fs::create_dir_all(&foreign_dir).unwrap(); - seed_recording(&foreign_dir, "10-00-00", Some("text")); + seed_recording(&foreign_dir, "2026-04-15T10-00-00Z", Some("text")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(foreign_case, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(foreign_case), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -216,12 +92,17 @@ async fn analyze_foreign_case_returns_404() { #[tokio::test] async fn analyze_invalid_uuid_returns_400() { - let config = config_with_llm(unique_tmp("c"), "http://unused".into()); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let cfg = cfg_llm("c"); + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request("not-a-uuid", &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze("not-a-uuid"), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -229,16 +110,21 @@ async fn analyze_invalid_uuid_returns_400() { #[tokio::test] async fn analyze_case_without_recordings_returns_400() { - let config = config_with_llm(unique_tmp("d"), "http://unused".into()); + let cfg = cfg_llm("d"); let case_id = "11111111-1111-1111-1111-111111111111"; - seed_case(&config.data_path, "dr_a", case_id); + seed_case(&cfg.data_path, "dr_a", case_id); // No .m4a files. - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -246,17 +132,22 @@ async fn analyze_case_without_recordings_returns_400() { #[tokio::test] async fn analyze_case_with_missing_transcript_returns_400() { - let config = config_with_llm(unique_tmp("e"), "http://unused".into()); + let cfg = cfg_llm("e"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); - seed_recording(&case_dir, "10-05-00", None); // still transcribing + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); + seed_recording(&case_dir, "2026-04-15T10-05-00Z", None); // still transcribing - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -264,16 +155,21 @@ async fn analyze_case_with_missing_transcript_returns_400() { #[tokio::test] async fn analyze_case_without_llm_returns_503() { - let config = config_without_llm(unique_tmp("f")); + let cfg = cfg_without_llm("f"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); @@ -285,21 +181,30 @@ async fn analyze_case_without_llm_returns_503() { #[tokio::test] async fn analyze_case_happy_path_writes_input_and_redirects() { - let config = config_with_llm(unique_tmp("g"), "http://unused".into()); + let cfg = cfg_llm("g"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("Patient klagt über Knie.")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording( &case_dir, - "10-05-00", + "2026-04-15T10-00-00Z", + Some("Patient klagt über Knie."), + ); + seed_recording( + &case_dir, + "2026-04-15T10-05-00Z", Some("Korrektur: links, nicht rechts."), ); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -327,23 +232,33 @@ async fn analyze_case_happy_path_writes_input_and_redirects() { #[tokio::test] async fn analyze_case_second_time_returns_409() { - let config = config_with_llm(unique_tmp("h"), "http://unused".into()); + let cfg = cfg_llm("h"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("text")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("text")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let first = app .clone() - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(first.status(), StatusCode::SEE_OTHER); let second = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT); @@ -351,18 +266,23 @@ async fn analyze_case_second_time_returns_409() { #[tokio::test] async fn analyze_case_skips_blank_transcript_from_recordings() { - let config = config_with_llm(unique_tmp("i"), "http://unused".into()); + let cfg = cfg_llm("i"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("echt")); - seed_recording(&case_dir, "10-05-00", Some(" ")); // blank - seed_recording(&case_dir, "10-10-00", Some("auch echt")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("echt")); + seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some(" ")); // blank + seed_recording(&case_dir, "2026-04-15T10-10-00Z", Some("auch echt")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -379,7 +299,7 @@ async fn analyze_case_skips_blank_transcript_from_recordings() { #[tokio::test] async fn analyze_worker_writes_document_via_wiremock() { - let tmp = unique_tmp("w"); + let tmp = unique_tmpdir("analyze-w"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); @@ -406,14 +326,20 @@ async fn analyze_worker_writes_document_via_wiremock() { .mount(&mock) .await; - let config = config_with_llm(tmp.clone(), mock.uri()); + let cfg = TestConfig::new() + .with_label("analyze-w") + .with_data_path(tmp.clone()) + .with_user(test_user("dr_a")) + .with_llm(mock.uri()) + .build(); + let (tx, rx) = analyze::channel(); let client = reqwest::Client::new(); - let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); + let busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty()); let handle = tokio::spawn(analyze::worker::run( rx, - config, + cfg, client, busy, vocab, @@ -452,7 +378,7 @@ async fn analyze_worker_writes_document_via_wiremock() { /// "Cerebrum", not the LLM's drift. #[tokio::test] async fn analyze_worker_normalizes_llm_output() { - let tmp = unique_tmp("g"); + let tmp = unique_tmpdir("analyze-gaz"); let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_dir).unwrap(); @@ -469,7 +395,7 @@ async fn analyze_worker_normalizes_llm_output() { .unwrap(); // Vocab directory with a single anatomy entry. - let vocab_dir = unique_tmp("vg"); + let vocab_dir = unique_tmpdir("analyze-vocab"); std::fs::create_dir_all(&vocab_dir).unwrap(); std::fs::write(vocab_dir.join("anatomy.txt"), "Cerebrum\n").unwrap(); let vocab = Arc::new( @@ -492,13 +418,19 @@ async fn analyze_worker_normalizes_llm_output() { .mount(&mock) .await; - let config = config_with_llm(tmp.clone(), mock.uri()); + let cfg = TestConfig::new() + .with_label("analyze-gaz") + .with_data_path(tmp.clone()) + .with_user(test_user("dr_a")) + .with_llm(mock.uri()) + .build(); + let (tx, rx) = analyze::channel(); let client = reqwest::Client::new(); - let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); let handle = tokio::spawn(analyze::worker::run( rx, - config, + cfg, client, busy, vocab, @@ -583,7 +515,7 @@ async fn replace_real_case_dry() { #[tokio::test] async fn recovery_enqueues_pending_analysis() { - let tmp = unique_tmp("r1"); + let tmp = unique_tmpdir("analyze-r1"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap(); @@ -598,7 +530,7 @@ async fn recovery_enqueues_pending_analysis() { #[tokio::test] async fn recovery_skips_completed_analysis() { - let tmp = unique_tmp("r2"); + let tmp = unique_tmpdir("analyze-r2"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap(); @@ -619,18 +551,23 @@ async fn recovery_skips_completed_analysis() { #[tokio::test] async fn analyze_deletes_old_document_and_writes_fresh_input() { - let config = config_with_llm(unique_tmp("rn-5"), "http://unused".into()); + let cfg = cfg_llm("rn-5"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join("document.md"), "altes Dokument").unwrap(); - seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme")); - seed_recording(&case_dir, "10-05-00", Some("zweite Aufnahme")); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme")); + seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some("zweite Aufnahme")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(analyze_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_analyze(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -652,16 +589,21 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { #[tokio::test] async fn close_writes_marker_and_hides_case() { - let config = config_with_llm(unique_tmp("close-1"), "http://unused".into()); + let cfg = cfg_llm("close-1"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -685,29 +627,28 @@ async fn close_writes_marker_and_hides_case() { #[tokio::test] async fn closed_case_returns_404_on_detail() { - let config = config_with_llm(unique_tmp("close-2"), "http://unused".into()); + let cfg = cfg_llm("close-2"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let resp = app - .oneshot( - Request::builder() - .uri(format!("/web/cases/{case_id}")) - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -715,25 +656,35 @@ async fn closed_case_returns_404_on_detail() { #[tokio::test] async fn reopen_removes_marker_and_restores_case() { - let config = config_with_llm(unique_tmp("reopen-1"), "http://unused".into()); + let cfg = cfg_llm("reopen-1"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; // Close, then reopen via the explicit endpoint. let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert!(case_dir.join(".closed").exists()); let resp = app .clone() - .oneshot(reopen_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_reopen(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -744,13 +695,7 @@ async fn reopen_removes_marker_and_restores_case() { // Case is addressable again at the detail route — no lingering state. let detail = app - .oneshot( - Request::builder() - .uri(format!("/web/cases/{case_id}")) - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) .await .unwrap(); assert_eq!(detail.status(), StatusCode::OK); @@ -758,63 +703,46 @@ async fn reopen_removes_marker_and_restores_case() { #[tokio::test] async fn show_closed_query_includes_closed_cases() { - let config = config_with_llm(unique_tmp("show-closed"), "http://unused".into()); + let cfg = cfg_llm("show-closed"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("a closed one")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("a closed one")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; // Close first. let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); // Default listing must NOT show it. - let resp = app - .clone() - .oneshot( - Request::builder() - .uri("/web/cases") - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = String::from_utf8( - axum::body::to_bytes(resp.into_body(), usize::MAX) + let body = common::body_string( + app.clone() + .oneshot(get_with_cookie(paths::CASES, &cookie)) .await - .unwrap() - .to_vec(), + .unwrap(), ) - .unwrap(); + .await; assert!( !body.contains(case_id), "default /web/cases must hide closed cases" ); // With show_closed=1 it must appear, with the closed badge. - let resp = app - .oneshot( - Request::builder() - .uri("/web/cases?show_closed=1") - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = String::from_utf8( - axum::body::to_bytes(resp.into_body(), usize::MAX) + let body = common::body_string( + app.oneshot(get_with_cookie("/web/cases?show_closed=1", &cookie)) .await - .unwrap() - .to_vec(), + .unwrap(), ) - .unwrap(); + .await; assert!( body.contains(case_id), "show_closed=1 must list closed cases" @@ -831,46 +759,41 @@ async fn show_closed_query_includes_closed_cases() { #[tokio::test] async fn closed_case_detail_with_show_closed_returns_200() { - let config = config_with_llm(unique_tmp("show-detail"), "http://unused".into()); + let cfg = cfg_llm("show-detail"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("x")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("x")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); - // Without query → 404 (covered by closed_case_returns_404_on_detail). // With ?show_closed=1 → 200 with the reopen form. - let resp = app - .oneshot( - Request::builder() - .uri(format!("/web/cases/{case_id}?show_closed=1")) - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) + let body = common::body_string( + app.oneshot(get_with_cookie( + &format!("{}?show_closed=1", paths::case_detail(case_id)), + &cookie, + )) .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = String::from_utf8( - axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), + .unwrap(), ) - .unwrap(); + .await; assert!( - body.contains(&format!("/web/cases/{case_id}/reopen")), + body.contains(&format!("{}/reopen", paths::case_detail(case_id))), "detail page of a closed case must offer a reopen form" ); assert!( - !body.contains(&format!("/web/cases/{case_id}/close\"")), + !body.contains(&format!("{}\"", paths::case_close(case_id))), "detail page of a closed case must NOT offer a close form" ); } @@ -879,27 +802,19 @@ async fn closed_case_detail_with_show_closed_returns_200() { async fn close_redirects_back_to_referer_query() { // Close from the show-closed listing must keep ?show_closed=1 so // the user stays in the view they were in. - let config = config_with_llm(unique_tmp("close-ref-q"), "http://unused".into()); + let cfg = cfg_llm("close-ref-q"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/close")) - .header(header::COOKIE, &cookie) - .header(header::REFERER, "/web/cases?show_closed=1") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) - .await - .unwrap(); + let mut req = csrf_form_post(paths::case_close(case_id), &cookie, &csrf, ""); + req.headers_mut() + .insert(header::REFERER, "/web/cases?show_closed=1".parse().unwrap()); + + let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers() @@ -916,30 +831,22 @@ async fn close_from_detail_redirects_to_list_preserving_query() { // Close from /web/cases/{uuid}?show_closed=1 must NOT redirect back // to the detail page (it 404s after the close). The uuid segment // is stripped; the query survives so the user lands in show-closed. - let config = config_with_llm(unique_tmp("close-ref-det"), "http://unused".into()); + let cfg = cfg_llm("close-ref-det"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/close")) - .header(header::COOKIE, &cookie) - .header( - header::REFERER, - format!("/web/cases/{case_id}?show_closed=1"), - ) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) - .await - .unwrap(); + let mut req = csrf_form_post(paths::case_close(case_id), &cookie, &csrf, ""); + req.headers_mut().insert( + header::REFERER, + format!("{}?show_closed=1", paths::case_detail(case_id)) + .parse() + .unwrap(), + ); + let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers() @@ -957,33 +864,29 @@ async fn reopen_redirects_back_to_referer() { // User reopens a case from the show-closed listing. The redirect // must preserve `?show_closed=1` so the user stays in that view // instead of being ejected back to the default open-only listing. - let config = config_with_llm(unique_tmp("reopen-ref"), "http://unused".into()); + let cfg = cfg_llm("reopen-ref"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/reopen")) - .header(header::COOKIE, &cookie) - .header(header::REFERER, "/web/cases?show_closed=1") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) - .await - .unwrap(); + let mut req = csrf_form_post(paths::case_reopen(case_id), &cookie, &csrf, ""); + req.headers_mut() + .insert(header::REFERER, "/web/cases?show_closed=1".parse().unwrap()); + let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers() @@ -998,16 +901,21 @@ async fn reopen_redirects_back_to_referer() { #[tokio::test] async fn reopen_is_noop_on_open_case() { - let config = config_with_llm(unique_tmp("reopen-2"), "http://unused".into()); + let cfg = cfg_llm("reopen-2"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(reopen_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_reopen(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1017,27 +925,33 @@ async fn reopen_is_noop_on_open_case() { #[tokio::test] async fn purge_closed_removes_directory() { // purge-closed is admin-only (matches the UI, which hides the bar). - let config = config_with_llm_users( - unique_tmp("purge-1"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); + let cfg = cfg_llm_admin("purge-1"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert!(case_dir.join(".closed").exists()); let resp = app - .oneshot(purge_closed_request(&cookie, &csrf, Some("yes"))) + .oneshot(csrf_form_post( + paths::PURGE_CLOSED, + &cookie, + &csrf, + "confirm=yes", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1049,26 +963,27 @@ async fn purge_closed_removes_directory() { #[tokio::test] async fn purge_requires_confirm_param() { - let config = config_with_llm_users( - unique_tmp("purge-2"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); + let cfg = cfg_llm_admin("purge-2"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); let resp = app - .oneshot(purge_closed_request(&cookie, &csrf, None)) + .oneshot(csrf_form_post(paths::PURGE_CLOSED, &cookie, &csrf, "")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -1081,29 +996,35 @@ async fn purge_requires_confirm_param() { #[tokio::test] async fn purge_closed_keeps_open_cases() { - let config = config_with_llm_users( - unique_tmp("purge-3"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); + let cfg = cfg_llm_admin("purge-3"); let closed_id = "11111111-1111-1111-1111-111111111111"; let open_id = "22222222-2222-2222-2222-222222222222"; - let dir_closed = seed_case(&config.data_path, "dr_a", closed_id); - let dir_open = seed_case(&config.data_path, "dr_a", open_id); - seed_recording(&dir_closed, "10-00-00", Some("a")); - seed_recording(&dir_open, "10-00-00", Some("b")); + let dir_closed = seed_case(&cfg.data_path, "dr_a", closed_id); + let dir_open = seed_case(&cfg.data_path, "dr_a", open_id); + seed_recording(&dir_closed, "2026-04-15T10-00-00Z", Some("a")); + seed_recording(&dir_open, "2026-04-15T10-00-00Z", Some("b")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let _ = app .clone() - .oneshot(close_request(closed_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(closed_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); let resp = app - .oneshot(purge_closed_request(&cookie, &csrf, Some("yes"))) + .oneshot(csrf_form_post( + paths::PURGE_CLOSED, + &cookie, + &csrf, + "confirm=yes", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1116,24 +1037,34 @@ async fn purge_closed_keeps_open_cases() { /// directory intact. #[tokio::test] async fn purge_closed_rejected_for_non_admin() { - // Default `make_user` is role=doctor. - let config = config_with_llm(unique_tmp("purge-nonadmin"), "http://unused".into()); + // Default `test_user` is role=doctor. + let cfg = cfg_llm("purge-nonadmin"); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; // Close the case first so there is something for purge to target. let _ = app .clone() - .oneshot(close_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); let resp = app - .oneshot(purge_closed_request(&cookie, &csrf, Some("yes"))) + .oneshot(csrf_form_post( + paths::PURGE_CLOSED, + &cookie, + &csrf, + "confirm=yes", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); @@ -1144,32 +1075,20 @@ async fn purge_closed_rejected_for_non_admin() { async fn bulk_close_shares_one_closed_at_timestamp() { // Bulk actions are admin-only since the UI hides the selection // from non-admins; the handler enforces the same gate. - let config = config_with_llm_users( - unique_tmp("bulk-c"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); + let cfg = cfg_llm_admin("bulk-c"); let case_a = "11111111-1111-1111-1111-111111111111"; let case_b = "22222222-2222-2222-2222-222222222222"; - let dir_a = seed_case(&config.data_path, "dr_a", case_a); - let dir_b = seed_case(&config.data_path, "dr_a", case_b); - seed_recording(&dir_a, "10-00-00", Some("a")); - seed_recording(&dir_b, "10-05-00", Some("b")); + let dir_a = seed_case(&cfg.data_path, "dr_a", case_a); + let dir_b = seed_case(&cfg.data_path, "dr_a", case_b); + seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a")); + seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let body = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}"); + let body = format!("action=close&case_id={case_a}&case_id={case_b}"); let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/cases/bulk") - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) + .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1187,32 +1106,20 @@ async fn bulk_close_shares_one_closed_at_timestamp() { #[tokio::test] async fn bulk_analyze_processes_each_selected_case() { // Bulk analyze is admin-only (UI + handler). - let config = config_with_llm_users( - unique_tmp("bulk-a"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); + let cfg = cfg_llm_admin("bulk-a"); let case_a = "11111111-1111-1111-1111-111111111111"; let case_b = "22222222-2222-2222-2222-222222222222"; - let dir_a = seed_case(&config.data_path, "dr_a", case_a); - let dir_b = seed_case(&config.data_path, "dr_a", case_b); - seed_recording(&dir_a, "10-00-00", Some("a")); - seed_recording(&dir_b, "10-05-00", Some("b")); + let dir_a = seed_case(&cfg.data_path, "dr_a", case_a); + let dir_b = seed_case(&cfg.data_path, "dr_a", case_b); + seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a")); + seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let body = format!("action=analyze&case_id={case_a}&case_id={case_b}&csrf_token={csrf}"); + let body = format!("action=analyze&case_id={case_a}&case_id={case_b}"); let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/cases/bulk") - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) + .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1223,7 +1130,7 @@ async fn bulk_analyze_processes_each_selected_case() { #[tokio::test] async fn recovery_skips_closed_cases() { - let tmp = unique_tmp("rec-closed"); + let tmp = unique_tmpdir("analyze-rec-closed"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap(); @@ -1243,29 +1150,14 @@ async fn recovery_skips_closed_cases() { // Admin-only reset // --------------------------------------------------------------------- -fn reset_request(case_id: &str, cookie: &str, csrf: &str) -> Request { - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/reset")) - .header(header::COOKIE, cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap() -} - #[tokio::test] async fn reset_case_clears_derived_and_unfails_audio() { - let tmp = unique_tmp("reset-admin"); - let config = config_with_llm_users( - tmp.clone(), - "http://unused".into(), - vec![make_admin("dr_a")], - ); + let cfg = cfg_llm_admin("reset-admin"); let case_id = "11111111-1111-1111-1111-111111111111"; - let dir = seed_case(&config.data_path, "dr_a", case_id); + let dir = seed_case(&cfg.data_path, "dr_a", case_id); // Good recording with transcript. - seed_recording(&dir, "09-00-00", Some("hallo")); + seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo")); // A failed recording: raw audio written, but filename carries `.failed`. std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap(); // Derived artefacts the reset must wipe. @@ -1277,11 +1169,16 @@ async fn reset_case_clears_derived_and_unfails_audio() { std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); std::fs::write(dir.join("analysis_input.json"), "{}").unwrap(); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(reset_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_reset(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1300,19 +1197,23 @@ async fn reset_case_clears_derived_and_unfails_audio() { #[tokio::test] async fn reset_case_requires_admin() { - let tmp = unique_tmp("reset-nonadmin"); - // Default `make_user` has role=doctor, not admin. - let config = config_with_llm(tmp.clone(), "http://unused".into()); + // Default `test_user` has role=doctor, not admin. + let cfg = cfg_llm("reset-nonadmin"); let case_id = "11111111-1111-1111-1111-111111111111"; - let dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&dir, "09-00-00", Some("hallo")); + let dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo")); std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app - .oneshot(reset_request(case_id, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::case_reset(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); @@ -1324,42 +1225,34 @@ async fn reset_case_requires_admin() { #[tokio::test] async fn bulk_reset_processes_multiple_cases_admin_only() { - let tmp = unique_tmp("bulk-reset"); - let config = config_with_llm_users( - tmp.clone(), - "http://unused".into(), - vec![make_admin("dr_a"), make_user("dr_b")], - ); + let cfg = TestConfig::new() + .with_label("bulk-reset") + .with_user(test_admin("dr_a")) + .with_user(test_user("dr_b")) + .with_llm("http://unused") + .build(); let case_a = "11111111-1111-1111-1111-111111111111"; let case_b = "22222222-2222-2222-2222-222222222222"; - let dir_a = seed_case(&config.data_path, "dr_a", case_a); - let dir_b = seed_case(&config.data_path, "dr_a", case_b); - seed_recording(&dir_a, "10-00-00", Some("a")); - seed_recording(&dir_b, "10-05-00", Some("b")); + let dir_a = seed_case(&cfg.data_path, "dr_a", case_a); + let dir_b = seed_case(&cfg.data_path, "dr_a", case_b); + seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a")); + seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b")); std::fs::write(dir_a.join("document.md"), "A").unwrap(); std::fs::write(dir_b.join("document.md"), "B").unwrap(); // dr_b gets its own case to verify non-admin branch without clobbering dr_a. let case_c = "33333333-3333-3333-3333-333333333333"; - let dir_c = seed_case(&config.data_path, "dr_b", case_c); - seed_recording(&dir_c, "11-00-00", Some("c")); + let dir_c = seed_case(&cfg.data_path, "dr_b", case_c); + seed_recording(&dir_c, "2026-04-15T11-00-00Z", Some("c")); std::fs::write(dir_c.join("document.md"), "C").unwrap(); - let (app, store) = doctate_server::create_router_and_session_store(config); + let (app, store) = doctate_server::create_router_and_session_store(cfg); // Admin: bulk reset succeeds. - let (cookie_admin, csrf_admin) = login(app.clone(), &store, "dr_a").await; - let body = format!("action=reset&case_id={case_a}&case_id={case_b}&csrf_token={csrf_admin}"); + let (cookie_admin, csrf_admin) = login_with_csrf(&app, &store, "dr_a", "s").await; + let body = format!("action=reset&case_id={case_a}&case_id={case_b}"); let resp = app .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/web/cases/bulk") - .header(header::COOKIE, &cookie_admin) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) + .oneshot(csrf_form_post(paths::BULK, &cookie_admin, &csrf_admin, &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1369,18 +1262,15 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists()); // Non-admin: bulk reset is rejected, dr_b's case untouched. - let (cookie_doctor, csrf_doctor) = login(app.clone(), &store, "dr_b").await; - let body = format!("action=reset&case_id={case_c}&csrf_token={csrf_doctor}"); + let (cookie_doctor, csrf_doctor) = login_with_csrf(&app, &store, "dr_b", "s").await; + let body = format!("action=reset&case_id={case_c}"); let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/cases/bulk") - .header(header::COOKIE, &cookie_doctor) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) + .oneshot(csrf_form_post( + paths::BULK, + &cookie_doctor, + &csrf_doctor, + &body, + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); @@ -1394,29 +1284,18 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { /// renders the bulk bar for non-admins. #[tokio::test] async fn bulk_close_rejected_for_non_admin() { - let tmp = unique_tmp("bulk-close-nonadmin"); - // Default `make_user` is role=doctor. - let config = config_with_llm(tmp.clone(), "http://unused".into()); + // Default `test_user` is role=doctor. + let cfg = cfg_llm("bulk-close-nonadmin"); let case_id = "11111111-1111-1111-1111-111111111111"; - let dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&dir, "10-00-00", Some("a")); + let dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&dir, "2026-04-15T10-00-00Z", Some("a")); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - // Include csrf_token so the admin check is the gate that fires, - // not the CSRF check — this test verifies the admin gate, not CSRF. - let body = format!("action=close&case_id={case_id}&csrf_token={csrf}"); + let body = format!("action=close&case_id={case_id}"); let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/cases/bulk") - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) + .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); @@ -1436,7 +1315,7 @@ async fn bulk_close_rejected_for_non_admin() { /// accidentally sends `Authorization: Bearer `. #[tokio::test] async fn analyze_works_against_ollama_style_endpoint_without_api_key() { - let tmp = unique_tmp("ollama"); + let tmp = unique_tmpdir("analyze-ollama"); let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_dir).unwrap(); @@ -1469,30 +1348,23 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() { .mount(&mock) .await; - // Ollama-style config: explicitly empty api_key. Inline because - // `config_with_llm` hard-codes "test-key" for hosted-provider tests. - let users = vec![make_user("dr_a")]; - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - let config = Arc::new(Config { - data_path: tmp.clone(), - users, - api_keys, - llm_url: mock.uri(), - llm_api_key: String::new(), - llm_model: "llama3.3:70b".into(), - ..Config::test_default() - }); + // Ollama-style config: explicitly empty api_key — `with_llm_explicit` + // lets the builder carry "" through, whereas `with_llm` would default + // to the hosted-provider test key. + let cfg = TestConfig::new() + .with_label("analyze-ollama") + .with_data_path(tmp.clone()) + .with_user(test_user("dr_a")) + .with_llm_explicit(mock.uri(), "", "llama3.3:70b") + .build(); let (tx, rx) = analyze::channel(); let client = reqwest::Client::new(); - let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); + let busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty()); let handle = tokio::spawn(analyze::worker::run( rx, - config, + cfg, client, busy, vocab, diff --git a/server/tests/auth_test.rs b/server/tests/auth_test.rs index 56bb6b8..5258a14 100644 --- a/server/tests/auth_test.rs +++ b/server/tests/auth_test.rs @@ -1,40 +1,22 @@ -use std::collections::HashMap; -use std::sync::Arc; +mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; -use doctate_server::config::{Config, User}; - -fn test_config() -> Arc { - Arc::new(Config { - data_path: "/tmp/doctate-test".into(), - log_path: "/tmp/doctate-test/logs".into(), - users: vec![User { - slug: "dr_test".into(), - api_key: "test-key-123".into(), - web_password: "unused".into(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - }], - api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), - ..Config::test_default() - }) -} +use common::{TestConfig, test_user}; #[tokio::test] async fn valid_api_key_returns_user() { - let app = doctate_server::create_router(test_config()); + let app = doctate_server::create_router( + TestConfig::new().with_user(test_user("dr_test")).build(), + ); let response = app .oneshot( Request::builder() .uri("/api/debug/whoami") - .header("X-API-Key", "test-key-123") + .header("X-API-Key", "key-dr_test") .body(Body::empty()) .unwrap(), ) @@ -43,18 +25,16 @@ async fn valid_api_key_returns_user() { assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - + let json = common::body_json(response).await; assert_eq!(json["slug"], "dr_test"); assert_eq!(json["role"], "doctor"); } #[tokio::test] async fn invalid_api_key_returns_401() { - let app = doctate_server::create_router(test_config()); + let app = doctate_server::create_router( + TestConfig::new().with_user(test_user("dr_test")).build(), + ); let response = app .oneshot( @@ -72,7 +52,9 @@ async fn invalid_api_key_returns_401() { #[tokio::test] async fn missing_api_key_returns_401() { - let app = doctate_server::create_router(test_config()); + let app = doctate_server::create_router( + TestConfig::new().with_user(test_user("dr_test")).build(), + ); let response = app .oneshot( diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 73e7205..c49f260 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -1,140 +1,19 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +mod common; -use axum::body::Body; -use axum::http::{Request, StatusCode, header}; -use doctate_common::oneliners::OnelinerState; -use doctate_server::config::{Config, User}; +use axum::http::StatusCode; use tower::util::ServiceExt; -fn unique_tmp(label: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "doctate-case-page-{label}-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )) -} +use doctate_common::oneliners::OnelinerState; -fn make_user(slug: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash("s", 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } -} +use common::{ + TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, + seed_oneliner_state, seed_recording, test_admin, test_user, +}; -fn make_admin(slug: &str) -> User { - let mut u = make_user(slug); - u.role = "admin".into(); - u -} - -fn seed_oneliner(case_dir: &Path, text: &str) { - let state = OnelinerState::Ready { - text: text.to_owned(), - generated_at: "2026-04-18T10:00:00Z".into(), - }; - let bytes = serde_json::to_vec(&state).unwrap(); - std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap(); -} - -fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) { - let bytes = serde_json::to_vec(state).unwrap(); - std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap(); -} - -fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - llm_url, - llm_api_key: "test-key".into(), - llm_model: "test-model".into(), - ..Config::test_default() - }) -} - -fn config_with_llm(data_path: PathBuf) -> Arc { - config_with_llm_users(data_path, "http://unused".into(), vec![make_user("dr_a")]) -} - -fn config_without_llm(data_path: PathBuf) -> Arc { - let users = vec![make_user("dr_a")]; - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - ..Config::test_default() - }) -} - -fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { - let dir = data_path.join(slug).join(case_id); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) { - let filename = format!("2026-04-15T{ts_hms}Z.m4a"); - std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap(); - if let Some(text) = transcript { - let tx_name = format!("2026-04-15T{ts_hms}Z.transcript.txt"); - std::fs::write(case_dir.join(tx_name), text).unwrap(); - } -} - -async fn login(app: axum::Router, slug: &str) -> String { - let body = format!("slug={slug}&password=s"); - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/login") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - for v in resp.headers().get_all(header::SET_COOKIE).iter() { - let s = v.to_str().unwrap(); - if let Some(pair) = s.split(';').next() - && pair.starts_with("session=") - { - return pair.to_string(); - } - } - panic!("no session cookie after login"); -} - -async fn body_string(resp: axum::response::Response) -> String { - let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - String::from_utf8(bytes.to_vec()).unwrap() -} - -fn get_page(case_id: &str, cookie: &str, suffix: &str) -> Request { - Request::builder() - .uri(format!("/web/cases/{case_id}{suffix}")) - .header(header::COOKIE, cookie) - .body(Body::empty()) - .unwrap() +/// Password "s" is the shared test default; wrap `login` so call sites +/// don't repeat the literal. +async fn login_dr_a(app: &axum::Router) -> String { + common::login(app, "dr_a", "s").await } // --------------------------------------------------------------------- @@ -143,36 +22,50 @@ fn get_page(case_id: &str, cookie: &str, suffix: &str) -> Request { #[tokio::test] async fn case_page_shows_document_when_present() { - let config = config_with_llm(unique_tmp("cp-doc")); + let cfg = TestConfig::new() + .with_label("cp-doc") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join("document.md"), "der Inhalt").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_string(resp).await; assert!(body.contains("der Inhalt"), "document body missing"); assert!( - body.contains(&format!("/web/cases/{case_id}/recordings")), + body.contains(&paths::case_recordings(case_id)), "recordings sub-page link missing" ); } #[tokio::test] async fn case_page_document_has_copy_button() { - let config = config_with_llm(unique_tmp("cp-copy")); + let cfg = TestConfig::new() + .with_label("cp-copy") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join("document.md"), "kopiere mich").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!(body.contains(r#"id="copy-btn""#), "copy button missing"); @@ -184,19 +77,30 @@ async fn case_page_document_has_copy_button() { #[tokio::test] async fn case_page_shows_analyze_button_when_transcribed_without_document() { - let config = config_with_llm(unique_tmp("cp-analyze")); + let cfg = TestConfig::new() + .with_label("cp-analyze") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("transkribierter Text")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording( + &case_dir, + "2026-04-15T10-00-00Z", + Some("transkribierter Text"), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!( - body.contains(&format!(r#"action="/web/cases/{case_id}/analyze""#)), + body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))), "analyze form missing" ); assert!( @@ -207,15 +111,21 @@ async fn case_page_shows_analyze_button_when_transcribed_without_document() { #[tokio::test] async fn case_page_shows_llm_missing_hint_without_llm() { - let config = config_without_llm(unique_tmp("cp-llm")); + let cfg = TestConfig::new() + .with_label("cp-llm") + .with_user(test_user("dr_a")) + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("Text")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("Text")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!( @@ -226,14 +136,21 @@ async fn case_page_shows_llm_missing_hint_without_llm() { #[tokio::test] async fn case_page_empty_case_shows_placeholder() { - let config = config_with_llm(unique_tmp("cp-empty")); + let cfg = TestConfig::new() + .with_label("cp-empty") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - seed_case(&config.data_path, "dr_a", case_id); + seed_case(&cfg.data_path, "dr_a", case_id); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_string(resp).await; @@ -242,25 +159,31 @@ async fn case_page_empty_case_shows_placeholder() { "empty-case placeholder missing" ); assert!( - body.contains(&format!(r#"action="/web/cases/{case_id}/close""#)), + body.contains(&format!(r#"action="{}""#, paths::case_close(case_id))), "close form missing on empty case" ); } #[tokio::test] async fn case_page_foreign_user_returns_404() { - let data_path = unique_tmp("cp-idor"); - let users = vec![make_user("dr_a"), make_user("dr_b")]; - let config = config_with_llm_users(data_path, "http://unused".into(), users); + let cfg = TestConfig::new() + .with_label("cp-idor") + .with_user(test_user("dr_a")) + .with_user(test_user("dr_b")) + .with_llm("http://unused") + .build(); let case_id = "22222222-2222-2222-2222-222222222222"; // Case exists under dr_b, but session is dr_a → 404. - let case_dir = seed_case(&config.data_path, "dr_b", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); std::fs::write(case_dir.join("document.md"), "fremdes Dokument").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } @@ -270,17 +193,21 @@ async fn case_page_foreign_user_returns_404() { #[tokio::test] async fn case_recordings_shows_all_recordings() { - let config = config_with_llm(unique_tmp("rec-list")); + let cfg = TestConfig::new() + .with_label("rec-list") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme")); - seed_recording(&case_dir, "11-00-00", None); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme")); + seed_recording(&case_dir, "2026-04-15T11-00-00Z", None); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; let resp = app - .oneshot(get_page(case_id, &cookie, "/recordings")) + .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -294,38 +221,49 @@ async fn case_recordings_shows_all_recordings() { #[tokio::test] async fn case_recordings_back_link_targets_case_page() { - let config = config_with_llm(unique_tmp("rec-back")); + let cfg = TestConfig::new() + .with_label("rec-back") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", None); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", None); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; let resp = app - .oneshot(get_page(case_id, &cookie, "/recordings")) + .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!( - body.contains(&format!(r#"href="/web/cases/{case_id}""#)), + body.contains(&format!(r#"href="{}""#, paths::case_detail(case_id))), "back link must target the case page, not /web/cases" ); } #[tokio::test] async fn case_page_uses_oneliner_as_h1_when_present() { - let config = config_with_llm(unique_tmp("cp-h1")); + let cfg = TestConfig::new() + .with_label("cp-h1") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_oneliner(&case_dir, "Herzkatheter unauffällig"); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_oneliner_ready(&case_dir, "Herzkatheter unauffällig"); std::fs::write(case_dir.join("document.md"), "Inhalt").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; // H1 wraps the oneliner; the short case_id fallback must be gone. @@ -349,13 +287,15 @@ async fn case_page_uses_oneliner_as_h1_when_present() { /// cases stuck on "generiere Titel …" forever. #[tokio::test] async fn case_page_silent_only_shows_empty_not_generating() { - let config = config_with_llm(unique_tmp("cp-silent")); + let cfg = TestConfig::new() + .with_label("cp-silent") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "33333333-3333-3333-3333-333333333333"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - // `Some("")` produces a 0-byte transcript sidecar — the whisper-as- - // silence output. Combined with `OnelinerState::Empty`, this is the - // terminal shape of a silent-only case after the fix. - seed_recording(&case_dir, "10-00-00", Some("")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + // Some("") → 0-byte transcript sidecar — whisper-as-silence output. + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("")); seed_oneliner_state( &case_dir, &OnelinerState::Empty { @@ -363,10 +303,13 @@ async fn case_page_silent_only_shows_empty_not_generating() { }, ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!( @@ -384,9 +327,13 @@ async fn case_page_falls_back_to_generic_title_for_empty_state() { // An `Empty` state (LLM deliberately returned no medical content) is // not a text; the H1 must fall back to the generic "Fall" label, not // leak the kind string or crash the template. - let config = config_with_llm(unique_tmp("cp-empty")); + let cfg = TestConfig::new() + .with_label("cp-empty-gen") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "22222222-2222-2222-2222-222222222222"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_oneliner_state( &case_dir, &OnelinerState::Empty { @@ -394,10 +341,13 @@ async fn case_page_falls_back_to_generic_title_for_empty_state() { }, ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!(body.contains("Fall"), "expected generic 'Fall' fallback"); @@ -409,16 +359,21 @@ async fn case_page_falls_back_to_generic_title_for_empty_state() { #[tokio::test] async fn case_page_admin_sees_admin_view_toggle() { - let data_path = unique_tmp("cp-toggle"); - let users = vec![make_admin("dr_admin")]; - let config = config_with_llm_users(data_path, "http://unused".into(), users); + let cfg = TestConfig::new() + .with_label("cp-toggle") + .with_user(test_admin("dr_admin")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - seed_case(&config.data_path, "dr_admin", case_id); + seed_case(&cfg.data_path, "dr_admin", case_id); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_admin").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_admin", "s").await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!( @@ -429,14 +384,21 @@ async fn case_page_admin_sees_admin_view_toggle() { #[tokio::test] async fn case_page_non_admin_has_no_admin_view_toggle() { - let config = config_with_llm(unique_tmp("cp-no-toggle")); + let cfg = TestConfig::new() + .with_label("cp-no-toggle") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - seed_case(&config.data_path, "dr_a", case_id); + seed_case(&cfg.data_path, "dr_a", case_id); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!( @@ -447,16 +409,21 @@ async fn case_page_non_admin_has_no_admin_view_toggle() { #[tokio::test] async fn case_page_admin_sees_full_case_id_meta() { - let data_path = unique_tmp("cp-admin"); - let users = vec![make_admin("dr_admin")]; - let config = config_with_llm_users(data_path, "http://unused".into(), users); + let cfg = TestConfig::new() + .with_label("cp-admin") + .with_user(test_admin("dr_admin")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - seed_case(&config.data_path, "dr_admin", case_id); + seed_case(&cfg.data_path, "dr_admin", case_id); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_admin").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_admin", "s").await; - let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); let body = body_string(resp).await; assert!( @@ -467,28 +434,32 @@ async fn case_page_admin_sees_full_case_id_meta() { #[tokio::test] async fn case_recordings_breadcrumb_includes_oneliner() { - let config = config_with_llm(unique_tmp("rec-breadcrumb")); + let cfg = TestConfig::new() + .with_label("rec-breadcrumb") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_oneliner(&case_dir, "Kurzer Befund"); - seed_recording(&case_dir, "10-00-00", None); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_oneliner_ready(&case_dir, "Kurzer Befund"); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", None); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; let resp = app - .oneshot(get_page(case_id, &cookie, "/recordings")) + .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; // Two separate breadcrumb links: back to the case list and back to the case page. assert!( - body.contains(r#"href="/web/cases""#), + body.contains(&format!(r#"href="{}""#, paths::CASES)), "breadcrumb must link back to the case list" ); assert!( - body.contains(&format!(r#"href="/web/cases/{case_id}""#)), + body.contains(&format!(r#"href="{}""#, paths::case_detail(case_id))), "breadcrumb must link back to the case page" ); assert!( @@ -499,26 +470,30 @@ async fn case_recordings_breadcrumb_includes_oneliner() { #[tokio::test] async fn case_recordings_has_no_action_buttons() { - let config = config_with_llm(unique_tmp("rec-ro")); + let cfg = TestConfig::new() + .with_label("rec-ro") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("Text")); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("Text")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; let resp = app - .oneshot(get_page(case_id, &cookie, "/recordings")) + .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!( - !body.contains(&format!(r#"action="/web/cases/{case_id}/analyze""#)), + !body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))), "recordings page must not expose the analyze action" ); assert!( - !body.contains(&format!(r#"action="/web/cases/{case_id}/close""#)), + !body.contains(&format!(r#"action="{}""#, paths::case_close(case_id))), "recordings page must not expose the close action" ); } diff --git a/server/tests/close_watermark_test.rs b/server/tests/close_watermark_test.rs index 43e45b6..96b2722 100644 --- a/server/tests/close_watermark_test.rs +++ b/server/tests/close_watermark_test.rs @@ -6,92 +6,23 @@ //! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits //! gelöscht). -use std::collections::HashMap; -use std::sync::Arc; +mod common; -use axum::body::Body; -use axum::http::{Request, StatusCode, header}; -use doctate_common::constants::API_KEY_HEADER; -use doctate_common::oneliners::ONELINERS_PATH; -use doctate_server::config::{Config, User}; +use std::path::Path; + +use axum::http::StatusCode; use tower::util::ServiceExt; -fn make_user(slug: &str, password_plain: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash(password_plain, 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } -} - -fn make_admin(slug: &str, password_plain: &str) -> User { - let mut u = make_user(slug, password_plain); - u.role = "admin".into(); - u -} - -fn test_config_with_users(users: Vec) -> Arc { - let data_path = std::env::temp_dir().join(format!( - "doctate-close-wm-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - let api_keys: HashMap = 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 { - 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 { - 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() -} - -fn oneliners_request(api_key: &str, if_none_match: Option<&str>) -> Request { - let mut b = Request::builder() - .method("GET") - .uri(ONELINERS_PATH) - .header(API_KEY_HEADER, api_key); - if let Some(tag) = if_none_match { - b = b.header(header::IF_NONE_MATCH, tag); - } - b.body(Body::empty()).unwrap() -} +use common::{ + TestConfig, csrf_form_post, get_with_api_key, get_with_api_key_if_none_match, header_opt, + login_with_csrf, paths, test_admin, test_user, +}; /// Create `///.m4a` so the case is -/// visible to `enumerate_recent_oneliners` (window defaults to 16h; +/// visible to `enumerate_recent_oneliners` (window defaults to 72h; /// the fake mtime is "now" because write happens right now). -fn seed_case(data_path: &std::path::Path, slug: &str, case_id: &str) { - let case_dir = data_path.join(slug).join(case_id); - std::fs::create_dir_all(&case_dir).unwrap(); +fn seed_case_with_recording(data_path: &Path, slug: &str, case_id: &str) { + let case_dir = common::seed_case(data_path, slug, case_id); std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a"), b"fake audio").unwrap(); } @@ -100,42 +31,24 @@ async fn capture_etag( api_key: &str, if_none_match: Option<&str>, ) -> (StatusCode, Option) { - let resp = app - .clone() - .oneshot(oneliners_request(api_key, if_none_match)) - .await - .unwrap(); + let req = match if_none_match { + Some(tag) => get_with_api_key_if_none_match(paths::ONELINERS_PATH, api_key, tag), + None => get_with_api_key(paths::ONELINERS_PATH, api_key), + }; + let resp = app.clone().oneshot(req).await.unwrap(); let status = resp.status(); - let etag = resp - .headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); + let etag = header_opt(&resp, "etag").map(str::to_owned); (status, etag) } #[tokio::test] async fn close_bumps_watermark() { - let config = test_config_with_users(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "550e8400-e29b-41d4-a716-446655440000"; - seed_case(&data_path, "dr_a", case_id); + seed_case_with_recording(&cfg.data_path, "dr_a", case_id); - let (app, store) = doctate_server::create_router_and_session_store(config); - - let login = app - .clone() - .oneshot(login_request("dr_a", "s")) - .await - .unwrap(); - let cookie = extract_session_cookie(&login).expect("login should set cookie"); - let csrf = store - .read() - .await - .get(cookie.trim_start_matches("session=")) - .unwrap() - .csrf_token - .clone(); + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; // First GET: capture baseline ETag (watermark empty → seeded on 0). let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await; @@ -145,15 +58,12 @@ async fn close_bumps_watermark() { // Close via the web handler. let del = app .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/close")) - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!( @@ -178,38 +88,21 @@ async fn close_bumps_watermark() { #[tokio::test] async fn reopen_bumps_watermark() { - let config = test_config_with_users(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "660e8400-e29b-41d4-a716-446655440000"; - seed_case(&data_path, "dr_a", case_id); + seed_case_with_recording(&cfg.data_path, "dr_a", case_id); - let (app, store) = doctate_server::create_router_and_session_store(config); - - let login = app - .clone() - .oneshot(login_request("dr_a", "s")) - .await - .unwrap(); - let cookie = extract_session_cookie(&login).expect("login should set cookie"); - let csrf = store - .read() - .await - .get(cookie.trim_start_matches("session=")) - .unwrap() - .csrf_token - .clone(); + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; // Close first so reopen has something to restore; grab the post-close ETag. app.clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/close")) - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) + .oneshot(csrf_form_post( + paths::case_close(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); let (_, etag_after_close) = capture_etag(&app, "key-dr_a", None).await; @@ -218,15 +111,12 @@ async fn reopen_bumps_watermark() { // Reopen. let reopen = app .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/reopen")) - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) + .oneshot(csrf_form_post( + paths::case_reopen(case_id), + &cookie, + &csrf, + "", + )) .await .unwrap(); assert_eq!(reopen.status().as_u16() / 100, 3, "reopen should redirect"); @@ -246,45 +136,23 @@ async fn reopen_bumps_watermark() { async fn bulk_close_bumps_watermark() { // Bulk actions are admin-only: UI hides them from non-admins and // the handler rejects non-admin POSTs with 403. - let config = test_config_with_users(vec![make_admin("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_admin("dr_a")).build(); let case_a = "770e8400-e29b-41d4-a716-446655440000"; let case_b = "880e8400-e29b-41d4-a716-446655440000"; - seed_case(&data_path, "dr_a", case_a); - seed_case(&data_path, "dr_a", case_b); + seed_case_with_recording(&cfg.data_path, "dr_a", case_a); + seed_case_with_recording(&cfg.data_path, "dr_a", case_b); - let (app, store) = doctate_server::create_router_and_session_store(config); - - let login = app - .clone() - .oneshot(login_request("dr_a", "s")) - .await - .unwrap(); - let cookie = extract_session_cookie(&login).expect("login should set cookie"); - let csrf = store - .read() - .await - .get(cookie.trim_start_matches("session=")) - .unwrap() - .csrf_token - .clone(); + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await; let etag_before = etag_before.expect("ETag must be set"); // Form-encoded bulk close: action=close&case_id=a&case_id=b. - let form = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}"); + let body = format!("action=close&case_id={case_a}&case_id={case_b}"); let bulk = app .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/web/cases/bulk") - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(), - ) + .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await .unwrap(); assert_eq!(bulk.status().as_u16() / 100, 3, "bulk should redirect"); diff --git a/server/tests/common/config.rs b/server/tests/common/config.rs new file mode 100644 index 0000000..c3d55a5 --- /dev/null +++ b/server/tests/common/config.rs @@ -0,0 +1,163 @@ +//! `Config` construction for tests. +//! +//! Collapses the ~13 lookalike `test_config_*` / `build_config` helpers +//! scattered across the integration tests into one fluent builder. +//! Every builder call is a minimal override on top of +//! `Config::test_default()`; the builder automatically derives the +//! `api_key → slug` index map from the collected users. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use doctate_server::config::{Config, User}; + +/// Unique tempdir path for a given label. `process::id()` + UUID v4 +/// keeps parallel `cargo test` runs from colliding; the label lets +/// humans identify which test owns which leftover directory when +/// cleanup fails. +pub fn unique_tmpdir(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "doctate-test-{label}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )) +} + +/// Fluent builder for `Arc`. All methods take `self` by value +/// and return `Self` so calls chain naturally: +/// +/// ```ignore +/// let cfg = TestConfig::new() +/// .with_user(test_user("dr_a")) +/// .with_llm(mock.uri()) +/// .build(); +/// ``` +pub struct TestConfig { + data_path: Option, + users: Vec, + llm_url: Option, + llm_api_key: Option, + llm_model: Option, + whisper_url: Option, + whisper_timeout_seconds: Option, + ollama_url: Option, + label: &'static str, +} + +impl TestConfig { + /// Start a fresh builder. `label` is used only to name the tempdir + /// when no explicit `data_path` is set via `with_data_path`. + pub fn new() -> Self { + Self { + data_path: None, + users: Vec::new(), + llm_url: None, + llm_api_key: None, + llm_model: None, + whisper_url: None, + whisper_timeout_seconds: None, + ollama_url: None, + label: "common", + } + } + + /// Label used when auto-generating the tempdir name. + pub fn with_label(mut self, label: &'static str) -> Self { + self.label = label; + self + } + + /// Override the data path. By default `build()` generates a unique + /// tempdir under `std::env::temp_dir()`. + pub fn with_data_path(mut self, path: PathBuf) -> Self { + self.data_path = Some(path); + self + } + + /// Add a single user. Can be called repeatedly. + pub fn with_user(mut self, user: User) -> Self { + self.users.push(user); + self + } + + /// Replace the user list outright. + pub fn with_users(mut self, users: Vec) -> Self { + self.users = users; + self + } + + /// Configure a hosted-provider LLM with the common test defaults + /// (`api_key = "test-key"`, `model = "test-model"`). For Ollama-style + /// endpoints that expect no `Authorization` header, use + /// [`with_llm_explicit`] and pass an empty api_key. + pub fn with_llm(mut self, url: impl Into) -> Self { + self.llm_url = Some(url.into()); + self.llm_api_key.get_or_insert_with(|| "test-key".into()); + self.llm_model.get_or_insert_with(|| "test-model".into()); + self + } + + /// Configure the LLM with explicit values. `api_key` may be empty + /// to exercise the Ollama-style no-auth path. + pub fn with_llm_explicit( + mut self, + url: impl Into, + api_key: impl Into, + model: impl Into, + ) -> Self { + self.llm_url = Some(url.into()); + self.llm_api_key = Some(api_key.into()); + self.llm_model = Some(model.into()); + self + } + + /// Whisper ASR mock URL + timeout. + pub fn with_whisper(mut self, url: impl Into, timeout_seconds: u64) -> Self { + self.whisper_url = Some(url.into()); + self.whisper_timeout_seconds = Some(timeout_seconds); + self + } + + /// Ollama (oneliner generator) mock URL. + pub fn with_ollama(mut self, url: impl Into) -> Self { + self.ollama_url = Some(url.into()); + self + } + + /// Materialize the config. Panics only if the workspace-defaults in + /// `Config::test_default` themselves are broken (not reachable in + /// practice). + pub fn build(self) -> Arc { + let data_path = self + .data_path + .unwrap_or_else(|| unique_tmpdir(self.label)); + let api_keys: HashMap = self + .users + .iter() + .map(|u| (u.api_key.clone(), u.slug.clone())) + .collect(); + + let defaults = Config::test_default(); + Arc::new(Config { + data_path, + users: self.users, + api_keys, + llm_url: self.llm_url.unwrap_or(defaults.llm_url), + llm_api_key: self.llm_api_key.unwrap_or(defaults.llm_api_key), + llm_model: self.llm_model.unwrap_or(defaults.llm_model), + whisper_url: self.whisper_url.unwrap_or(defaults.whisper_url), + whisper_timeout_seconds: self + .whisper_timeout_seconds + .unwrap_or(defaults.whisper_timeout_seconds), + ollama_url: self.ollama_url.unwrap_or(defaults.ollama_url), + ..Config::test_default() + }) + } +} + +impl Default for TestConfig { + fn default() -> Self { + Self::new() + } +} diff --git a/server/tests/common/http.rs b/server/tests/common/http.rs new file mode 100644 index 0000000..9712416 --- /dev/null +++ b/server/tests/common/http.rs @@ -0,0 +1,164 @@ +//! HTTP request builders and response extraction helpers. +//! +//! The stock axum `Request::builder()…oneshot()` chain is verbose; +//! centralising the common shapes here turns ~8 repetitive lines per +//! call site into one function call, and localises URL/header shape +//! changes to this file. + +use axum::body::Body; +use axum::http::{Request, header}; +use axum::response::Response; + +use doctate_common::{ + API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, +}; + +/// Plain form-urlencoded POST with a session cookie. `body` is passed +/// verbatim; CSRF-protected calls should use [`csrf_form_post`] to add +/// the token automatically. +pub fn form_post>(uri: U, cookie: &str, body: impl Into) -> Request { + Request::builder() + .method("POST") + .uri(uri.as_ref()) + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body.into())) + .unwrap() +} + +/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically. +/// Pass an empty `extra_body` when the form has no other fields +/// (e.g. POST /web/cases/{id}/close). +pub fn csrf_form_post>( + uri: U, + cookie: &str, + csrf: &str, + extra_body: &str, +) -> Request { + let body = if extra_body.is_empty() { + format!("csrf_token={csrf}") + } else { + format!("{extra_body}&csrf_token={csrf}") + }; + form_post(uri, cookie, body) +} + +/// GET with a session cookie — authenticated `/web/*` pages. +pub fn get_with_cookie>(uri: U, cookie: &str) -> Request { + Request::builder() + .method("GET") + .uri(uri.as_ref()) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() +} + +/// GET with `X-API-Key` — the public `/api/*` surface used by the +/// watch/desktop clients. +pub fn get_with_api_key>(uri: U, api_key: &str) -> Request { + Request::builder() + .method("GET") + .uri(uri.as_ref()) + .header(API_KEY_HEADER, api_key) + .body(Body::empty()) + .unwrap() +} + +/// GET with `X-API-Key` and a conditional `If-None-Match` header. Used +/// by the oneliners ETag tests and the upload watermark-invalidation +/// tests. +pub fn get_with_api_key_if_none_match>( + uri: U, + api_key: &str, + if_none_match: &str, +) -> Request { + Request::builder() + .method("GET") + .uri(uri.as_ref()) + .header(API_KEY_HEADER, api_key) + .header(header::IF_NONE_MATCH, if_none_match) + .body(Body::empty()) + .unwrap() +} + +/// Consume a response body into raw bytes. +pub async fn body_bytes(response: Response) -> Vec { + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec() +} + +/// Consume a response body into a UTF-8 string. Panics on non-UTF-8 — +/// which is correct for the HTML/JSON responses these tests assert on. +pub async fn body_string(response: Response) -> String { + String::from_utf8(body_bytes(response).await).unwrap() +} + +/// Consume a response body into a `serde_json::Value`. Panics on +/// malformed JSON, which the test should want to surface loudly. +pub async fn body_json(response: Response) -> serde_json::Value { + let bytes = body_bytes(response).await; + serde_json::from_slice(&bytes).unwrap() +} + +/// Borrow a header value as a `&str`. Returns `None` if the header is +/// absent or not valid UTF-8. +pub fn header_opt<'a>(response: &'a Response, name: &str) -> Option<&'a str> { + response.headers().get(name).and_then(|v| v.to_str().ok()) +} + +/// Header value as an owned `String`, `""` if missing. Matches the +/// existing style in `oneliners_api_test.rs`'s `header_str` helper. +pub fn header_str(response: &Response, name: &str) -> String { + header_opt(response, name).unwrap_or("").to_owned() +} + +/// Build a multipart body for `POST /api/upload`. Returns +/// `(content_type_header_value, body_bytes)` — pass the first as the +/// `Content-Type` header verbatim. +/// +/// Uses the server's canonical field names (`case_id`, `recorded_at`, +/// `audio`) from `doctate_common::constants`, so a server-side rename +/// propagates here for free. +pub fn multipart_upload_body( + case_id: &str, + recorded_at: &str, + audio: &[u8], +) -> (String, Vec) { + let boundary = "----testboundary"; + let mut body = Vec::new(); + + // case_id + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{FIELD_CASE_ID}\"\r\n\r\n").as_bytes(), + ); + body.extend_from_slice(case_id.as_bytes()); + body.extend_from_slice(b"\r\n"); + + // recorded_at + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{FIELD_RECORDED_AT}\"\r\n\r\n").as_bytes(), + ); + body.extend_from_slice(recorded_at.as_bytes()); + body.extend_from_slice(b"\r\n"); + + // audio + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{FIELD_AUDIO}\"; filename=\"test.m4a\"\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(format!("Content-Type: {CONTENT_TYPE_AUDIO_MP4}\r\n\r\n").as_bytes()); + body.extend_from_slice(audio); + body.extend_from_slice(b"\r\n"); + + // end + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + + (format!("multipart/form-data; boundary={boundary}"), body) +} diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs new file mode 100644 index 0000000..fb5dbcd --- /dev/null +++ b/server/tests/common/mod.rs @@ -0,0 +1,40 @@ +//! Shared test-support helpers for `server/tests/*.rs`. +//! +//! Every integration test file under `tests/*.rs` is a standalone crate. +//! This module's special filename `mod.rs` prevents Cargo from +//! compiling it as its own test target — it is only loaded by test +//! files that opt in via `mod common;` at the top. +//! +//! `#[allow(dead_code)]` is applied because each test crate uses only +//! a subset of the helpers; without the allow, rustc emits a forest of +//! warnings for the unused ones in each crate. + +#![allow(dead_code)] +// Re-exports below are visible to every test crate, but each crate only +// uses a subset — without the allow, rustc emits "unused import" +// warnings per crate. The helpers themselves are exercised; the +// warning is a false positive of the per-crate visibility model. +#![allow(unused_imports)] + +pub mod config; +pub mod http; +pub mod paths; +pub mod seed; +pub mod session; +pub mod users; + +// Re-exports for ergonomic `use common::*;` in most test files. +pub use config::{TestConfig, unique_tmpdir}; +pub use http::{ + body_json, body_string, csrf_form_post, form_post, get_with_api_key, + get_with_api_key_if_none_match, get_with_cookie, header_opt, header_str, multipart_upload_body, +}; +pub use seed::{ + past_rfc3339, seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording, + seed_recording_with_age, seed_recording_with_sidecars, write_closed_marker, +}; +pub use session::{extract_session_cookie, login, login_request, login_with_csrf}; +pub use users::{ + TEST_BCRYPT_COST, TEST_PASSWORD, test_admin, test_user, test_user_with_password, + test_user_with_retention, test_user_with_window_hours, +}; diff --git a/server/tests/common/paths.rs b/server/tests/common/paths.rs new file mode 100644 index 0000000..8ff393a --- /dev/null +++ b/server/tests/common/paths.rs @@ -0,0 +1,67 @@ +//! URL path constants and builders. +//! +//! Centralising these catches routing refactors at compile time (a +//! rename to the constant/fn shows up in every test file in one +//! `cargo check`) rather than as a scatter of 404s. +//! +//! The public `/api/*` surface lives in `doctate_common` and is +//! re-exported from there (see `ONELINERS_PATH`, `UPLOAD_PATH`). + +pub use doctate_common::{ONELINERS_PATH, UPLOAD_PATH}; + +// -- /web/* -- + +/// `POST /web/login` — form login (slug, password). +pub const LOGIN: &str = "/web/login"; +/// `POST /web/logout` — CSRF-protected logout. +pub const LOGOUT: &str = "/web/logout"; +/// `GET /web/cases` — case list; also triggers the lazy retention sweep. +pub const CASES: &str = "/web/cases"; +/// `POST /web/cases/bulk` — admin bulk action (close/analyze/reset). +pub const BULK: &str = "/web/cases/bulk"; +/// `POST /web/cases/purge-closed` — admin hard-delete of closed cases. +pub const PURGE_CLOSED: &str = "/web/cases/purge-closed"; +/// `GET /web/events` — SSE stream (per-user scoped). +pub const EVENTS: &str = "/web/events"; + +// -- per-case builders -- + +/// `/web/cases/{case_id}`. +pub fn case_detail(case_id: &str) -> String { + format!("/web/cases/{case_id}") +} + +/// `/web/cases/{case_id}/recordings`. +pub fn case_recordings(case_id: &str) -> String { + format!("/web/cases/{case_id}/recordings") +} + +/// `POST /web/cases/{case_id}/close`. +pub fn case_close(case_id: &str) -> String { + format!("/web/cases/{case_id}/close") +} + +/// `POST /web/cases/{case_id}/reopen`. +pub fn case_reopen(case_id: &str) -> String { + format!("/web/cases/{case_id}/reopen") +} + +/// `POST /web/cases/{case_id}/analyze`. +pub fn case_analyze(case_id: &str) -> String { + format!("/web/cases/{case_id}/analyze") +} + +/// `POST /web/cases/{case_id}/reset` — admin-only hard-reset. +pub fn case_reset(case_id: &str) -> String { + format!("/web/cases/{case_id}/reset") +} + +/// `POST /web/cases/{case_id}/recordings/delete` — per-recording delete. +pub fn recording_delete(case_id: &str) -> String { + format!("/web/cases/{case_id}/recordings/delete") +} + +/// `GET /web/magic?token={token}` — magic-link consumption. +pub fn magic(token: &str) -> String { + format!("/web/magic?token={token}") +} diff --git a/server/tests/common/seed.rs b/server/tests/common/seed.rs new file mode 100644 index 0000000..dde0256 --- /dev/null +++ b/server/tests/common/seed.rs @@ -0,0 +1,95 @@ +//! Filesystem-fixture builders for case directories, recordings, +//! oneliner state files, and close markers. +//! +//! All functions use synchronous `std::fs` (tests run inside +//! `#[tokio::test]` but these writes are short and their output is +//! consumed sequentially, so there is no win from async I/O here). A +//! few of the oneliner_api tests use `tokio::fs` directly because they +//! need to run inside the same async flow; that's fine — the two +//! coexist. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use doctate_common::oneliners::OnelinerState; +use filetime::FileTime; + +/// Create `///` and return its path. This is +/// the only "has to be on disk for the route to see it" fixture — +/// recordings and derived artefacts layer on top. +pub fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { + let dir = data_path.join(slug).join(case_id); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Write `.m4a` with placeholder bytes, plus optionally a +/// `.transcript.txt`. `stem` must be the full RFC3339-like +/// filename stem (e.g. `2026-04-15T10-00-00Z`); pass the same prefix +/// you want to read back later. Returns the audio filename (no path) +/// so the caller can feed it back to the delete endpoint. +pub fn seed_recording(case_dir: &Path, stem: &str, transcript: Option<&str>) -> String { + let filename = format!("{stem}.m4a"); + std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap(); + if let Some(text) = transcript { + std::fs::write(case_dir.join(format!("{stem}.transcript.txt")), text).unwrap(); + } + filename +} + +/// Like [`seed_recording`] but additionally writes the `.duration.txt` +/// sidecar — used by the delete-recording tests that assert every +/// sidecar is cleaned up. +pub fn seed_recording_with_sidecars( + case_dir: &Path, + stem: &str, + transcript: &str, +) -> String { + let filename = seed_recording(case_dir, stem, Some(transcript)); + std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap(); + filename +} + +/// Write an m4a with an artificial `mtime` in the past (relative to +/// now). Used by the retention-sweep tests to simulate "recorded N days +/// ago" without a clock abstraction. +pub fn seed_recording_with_age(case_dir: &Path, stem: &str, age: Duration) { + let filename = format!("{stem}.m4a"); + let path = case_dir.join(&filename); + std::fs::write(&path, b"audio").unwrap(); + let target = SystemTime::now() - age; + filetime::set_file_mtime(&path, FileTime::from_system_time(target)).unwrap(); +} + +/// Persist `OnelinerState::Ready { text }` with a fixed `generated_at`. +/// Used by UI-rendering tests that care about the displayed text, not +/// the timestamp. +pub fn seed_oneliner_ready(case_dir: &Path, text: &str) { + let state = OnelinerState::Ready { + text: text.to_owned(), + generated_at: "2026-04-18T10:00:00Z".into(), + }; + seed_oneliner_state(case_dir, &state); +} + +/// Persist an arbitrary [`OnelinerState`] as `oneliner.json`. +pub fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) { + let bytes = serde_json::to_vec(state).unwrap(); + std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap(); +} + +/// Write a `.closed` marker with a caller-chosen `closed_at` string. +/// Tests that need a specific age pass a historical RFC3339 stamp; a +/// helper for that is [`past_rfc3339`]. +pub fn write_closed_marker(case_dir: &Path, closed_at: &str) { + let json = format!(r#"{{"closed_at":"{closed_at}"}}"#); + std::fs::write(case_dir.join(".closed"), json).unwrap(); +} + +/// Format "now - age" as RFC3339 UTC. Used to mint historical +/// `closed_at` strings for retention-purge tests. +pub fn past_rfc3339(age: Duration) -> String { + use time::format_description::well_known::Rfc3339; + let t = time::OffsetDateTime::now_utc() - time::Duration::seconds(age.as_secs() as i64); + t.format(&Rfc3339).unwrap() +} diff --git a/server/tests/common/session.rs b/server/tests/common/session.rs new file mode 100644 index 0000000..ebfecdb --- /dev/null +++ b/server/tests/common/session.rs @@ -0,0 +1,84 @@ +//! Session cookie extraction + login flows. +//! +//! These were previously copy-pasted 4–6 times across the integration +//! tests. Centralising them here means: a change to the cookie format +//! or the login form field names affects one file, not six. + +use axum::Router; +use axum::body::Body; +use axum::http::{Request, header}; +use axum::response::Response; +use tower::util::ServiceExt; + +use doctate_server::web_session::SessionStore; + +use crate::common::paths; + +/// Extract the freshly-minted `session=` cookie from a login +/// response's `Set-Cookie` headers. Returns the whole `name=value` pair +/// (no attributes like `; Path=/`) so it can be sent back verbatim in a +/// `Cookie` header. +pub fn extract_session_cookie(resp: &Response) -> Option { + 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 +} + +/// Build the `POST /web/login` request with a URL-encoded form body. +/// Pulled out so tests that need to *inspect* the login response +/// (e.g. session-fixation tests) can reuse the exact wire shape. +pub fn login_request(slug: &str, password: &str) -> Request { + let body = format!("slug={slug}&password={password}"); + Request::builder() + .method("POST") + .uri(paths::LOGIN) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap() +} + +/// Log in, return the resulting `session=...` cookie string. +/// +/// Panics if the server didn't set a session cookie — meaning the +/// credentials were wrong or the route shape has changed. Tests that +/// deliberately exercise the failure path should use [`login_request`] +/// directly. +pub async fn login(app: &Router, slug: &str, password: &str) -> String { + let resp = app + .clone() + .oneshot(login_request(slug, password)) + .await + .unwrap(); + extract_session_cookie(&resp).expect("login did not set a session cookie") +} + +/// Log in and additionally fetch the session's CSRF token directly from +/// the session store. Pairs with +/// `doctate_server::create_router_and_session_store` — the returned +/// token is what the `CsrfForm` extractor validates on every +/// state-changing POST under `/web/`. +/// +/// Returns `(cookie_header_value, csrf_token)`. +pub async fn login_with_csrf( + app: &Router, + store: &SessionStore, + slug: &str, + password: &str, +) -> (String, String) { + let cookie = login(app, slug, password).await; + let token = cookie.trim_start_matches("session="); + let csrf = store + .read() + .await + .get(token) + .expect("session must be in the store right after login") + .csrf_token + .clone(); + (cookie, csrf) +} diff --git a/server/tests/common/users.rs b/server/tests/common/users.rs new file mode 100644 index 0000000..d714584 --- /dev/null +++ b/server/tests/common/users.rs @@ -0,0 +1,80 @@ +//! Factory functions for the `User` struct from `doctate_server::config`. +//! +//! Tests previously copy-pasted a 10-line `make_user` in every file. +//! These factories centralise the defaults (doctor role, bcrypt cost 4, +//! window 72 h, password "s") so an API change to `User` touches one +//! file, not twenty. + +use doctate_server::config::{RetentionUserSettings, User}; + +/// Bcrypt cost factor used in all tests. The production default is 12; +/// tests drop it to 4 because the hash is computed on every `test_user()` +/// call and a cost-12 hash adds ~200 ms per test. Security is irrelevant +/// for test-only passwords; speed is not. +pub const TEST_BCRYPT_COST: u32 = 4; + +/// Default plaintext password across tests. Individual tests that need +/// a specific password (e.g. for a login-wrong-password assertion) use +/// [`test_user_with_password`] instead. +pub const TEST_PASSWORD: &str = "s"; + +/// Bcrypt-hash the test password. Extracted so the cost factor lives in +/// one place. +fn hash_password(plain: &str) -> String { + bcrypt::hash(plain, TEST_BCRYPT_COST).unwrap() +} + +/// Doctor user. `api_key = "key-{slug}"` — kept deterministic so tests +/// that send the X-API-Key header can compute it from the slug without +/// keeping a separate constant. +pub fn test_user(slug: &str) -> User { + User { + slug: slug.into(), + api_key: format!("key-{slug}"), + web_password: hash_password(TEST_PASSWORD), + role: "doctor".into(), + whisper: Default::default(), + retention: Default::default(), + window_hours: 72, + preview_lines: 2, + } +} + +/// Admin variant — used by bulk/reset/purge-closed tests where the +/// handler enforces `role == "admin"`. +pub fn test_admin(slug: &str) -> User { + let mut u = test_user(slug); + u.role = "admin".into(); + u +} + +/// Doctor user with an explicit plaintext password. Used by tests that +/// log in with a specific password (e.g. wrong-password rejection). +pub fn test_user_with_password(slug: &str, password: &str) -> User { + let mut u = test_user(slug); + u.web_password = hash_password(password); + u +} + +/// Doctor user with custom retention settings — for the retention sweep +/// tests that drive the lazy auto-close / auto-purge logic. +pub fn test_user_with_retention( + slug: &str, + auto_close_days: u32, + auto_delete_days: u32, +) -> User { + let mut u = test_user(slug); + u.retention = RetentionUserSettings { + auto_close_days, + auto_delete_days, + }; + u +} + +/// Doctor user with a custom `window_hours` — used by `/api/oneliners` +/// tests that verify the visibility window is server-authoritative. +pub fn test_user_with_window_hours(slug: &str, hours: u32) -> User { + let mut u = test_user(slug); + u.window_hours = hours; + u +} diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs index b5a0f39..00a6a94 100644 --- a/server/tests/csrf_attack_test.rs +++ b/server/tests/csrf_attack_test.rs @@ -7,89 +7,16 @@ //! the `CsrfForm` 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) -> Arc { - let data_path = std::env::temp_dir().join(format!( - "doctate-csrf-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - let api_keys: HashMap = 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 { - 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 { - 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 { - 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 { /// 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(); diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index bab3138..76f1994 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -7,142 +7,47 @@ //! - rejects path-traversal filenames with 400 //! - enforces case ownership (returns 404 on cross-user access) -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +mod common; -use axum::body::Body; -use axum::http::{Request, StatusCode, header}; -use doctate_server::config::{Config, User}; +use axum::http::StatusCode; use tower::util::ServiceExt; -fn make_user(slug: &str, password_plain: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash(password_plain, 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } -} +use common::{ + TestConfig, csrf_form_post, login_with_csrf, paths, seed_case, seed_recording_with_sidecars, + test_user, +}; -fn test_config(users: Vec) -> Arc { - let data_path = std::env::temp_dir().join(format!( - "doctate-delete-rec-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - let api_keys: HashMap = 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 { - 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 { - 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( - 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 should set session cookie"); - let csrf = store - .read() - .await - .get(cookie.trim_start_matches("session=")) - .expect("session must be in store after login") - .csrf_token - .clone(); - (cookie, csrf) -} - -fn seed_case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { - let case_dir = data_path.join(slug).join(case_id); - std::fs::create_dir_all(&case_dir).unwrap(); - case_dir -} - -/// Write `.m4a` + matching `.transcript.txt` + `.duration.txt`, all -/// with placeholder content. Returns the audio filename (no path). -fn seed_recording(case_dir: &Path, ts_stem: &str, transcript: &str) -> String { - let filename = format!("{ts_stem}.m4a"); - std::fs::write(case_dir.join(&filename), b"fake audio").unwrap(); - std::fs::write( - case_dir.join(format!("{ts_stem}.transcript.txt")), - transcript, - ) - .unwrap(); - std::fs::write(case_dir.join(format!("{ts_stem}.duration.txt")), "42").unwrap(); - filename -} - -fn delete_request(case_id: &str, filename: &str, cookie: &str, csrf: &str) -> Request { - let body = format!("filename={filename}&csrf_token={csrf}"); - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/recordings/delete")) - .header(header::COOKIE, cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap() +fn delete_body(filename: &str) -> String { + format!("filename={filename}") } #[tokio::test] async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { - let config = test_config(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case_dir(&data_path, "dr_a", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - let victim = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "delete me"); - let survivor = seed_recording(&case_dir, "2026-04-19T11-00-00Z", "keep me"); + let victim = + seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me"); + let survivor = + seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me"); // Derived artefacts the delete must also purge. std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); std::fs::write(case_dir.join("document.md"), b"# stale").unwrap(); std::fs::write(case_dir.join("analysis_input.json"), b"{}").unwrap(); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, &victim, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::recording_delete(case_id), + &cookie, + &csrf, + &delete_body(&victim), + )) .await .unwrap(); assert_eq!( @@ -191,22 +96,21 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { #[tokio::test] async fn delete_recording_rejects_path_traversal() { - let config = test_config(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "22222222-2222-2222-2222-222222222222"; - let case_dir = seed_case_dir(&data_path, "dr_a", case_id); - seed_recording(&case_dir, "2026-04-19T10-00-00Z", "safe"); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "safe"); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request( - case_id, - "../../../etc/passwd.m4a", + .oneshot(csrf_form_post( + paths::recording_delete(case_id), &cookie, &csrf, + &delete_body("../../../etc/passwd.m4a"), )) .await .unwrap(); @@ -222,17 +126,21 @@ async fn delete_recording_rejects_path_traversal() { #[tokio::test] async fn delete_recording_rejects_wrong_extension() { - let config = test_config(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "33333333-3333-3333-3333-333333333333"; - seed_case_dir(&data_path, "dr_a", case_id); + seed_case(&cfg.data_path, "dr_a", case_id); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, "document.md", &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::recording_delete(case_id), + &cookie, + &csrf, + &delete_body("document.md"), + )) .await .unwrap(); assert_eq!( @@ -245,18 +153,26 @@ async fn delete_recording_rejects_wrong_extension() { #[tokio::test] async fn delete_recording_cross_user_returns_404() { // User B owns the case; User A tries to delete a recording in it. - let config = test_config(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new() + .with_user(test_user("dr_a")) + .with_user(test_user("dr_b")) + .build(); let case_id = "44444444-4444-4444-4444-444444444444"; - let case_dir = seed_case_dir(&data_path, "dr_b", case_id); - let filename = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "bob's recording"); + let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); + let filename = + seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "bob's recording"); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie_a, csrf_a) = login(&app, &store, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie_a, csrf_a) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, &filename, &cookie_a, &csrf_a)) + .oneshot(csrf_form_post( + paths::recording_delete(case_id), + &cookie_a, + &csrf_a, + &delete_body(&filename), + )) .await .unwrap(); assert_eq!( @@ -273,19 +189,23 @@ async fn delete_recording_cross_user_returns_404() { #[tokio::test] async fn delete_last_recording_clears_case() { // Deleting the only recording must succeed, not error out. - let config = test_config(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "55555555-5555-5555-5555-555555555555"; - let case_dir = seed_case_dir(&data_path, "dr_a", case_id); - let only = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "lonely"); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + let only = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "lonely"); std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, &only, &cookie, &csrf)) + .oneshot(csrf_form_post( + paths::recording_delete(case_id), + &cookie, + &csrf, + &delete_body(&only), + )) .await .unwrap(); assert_eq!( @@ -303,22 +223,21 @@ async fn delete_last_recording_clears_case() { async fn delete_recording_accepts_failed_suffix() { // Recordings whose transcription failed are renamed to .m4a.failed. // The delete button is shown for them too — they should be deletable. - let config = test_config(vec![make_user("dr_a", "s")]); - let data_path = config.data_path.clone(); + let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "66666666-6666-6666-6666-666666666666"; - let case_dir = seed_case_dir(&data_path, "dr_a", case_id); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a.failed"), b"x").unwrap(); - let (app, store) = doctate_server::create_router_and_session_store(config); - let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request( - case_id, - "2026-04-19T10-00-00Z.m4a.failed", + .oneshot(csrf_form_post( + paths::recording_delete(case_id), &cookie, &csrf, + &delete_body("2026-04-19T10-00-00Z.m4a.failed"), )) .await .unwrap(); diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index 40f4b60..b64dafe 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -1,77 +1,20 @@ -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}; - -fn make_user(slug: &str, password_plain: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash(password_plain, 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } -} - -fn test_config_with_users(users: Vec) -> Arc { - let data_path = std::env::temp_dir().join(format!( - "doctate-login-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - ..Config::test_default() - }) -} - -async fn body_to_string(response: axum::response::Response) -> String { - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - String::from_utf8(bytes.to_vec()).unwrap() -} - -/// Extract the session cookie (name=value) from a Set-Cookie response. -fn extract_session_cookie(resp: &axum::response::Response) -> Option { - 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 { - 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() -} +use common::{ + TestConfig, body_string, extract_session_cookie, get_with_cookie, login_request, paths, + test_user_with_password, +}; #[tokio::test] async fn login_success_sets_session_cookie_and_redirects() { - let config = test_config_with_users(vec![make_user("dr_a", "secret")]); - let app = doctate_server::create_router(config); + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "secret")) + .build(); + let app = doctate_server::create_router(cfg); let resp = app.oneshot(login_request("dr_a", "secret")).await.unwrap(); assert_eq!( @@ -99,20 +42,24 @@ async fn login_success_sets_session_cookie_and_redirects() { #[tokio::test] async fn login_wrong_password_shows_error() { - let config = test_config_with_users(vec![make_user("dr_a", "secret")]); - let app = doctate_server::create_router(config); + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "secret")) + .build(); + let app = doctate_server::create_router(cfg); let resp = app.oneshot(login_request("dr_a", "wrong")).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(extract_session_cookie(&resp).is_none()); - let body = body_to_string(resp).await; + let body = body_string(resp).await; assert!(body.contains("Login fehlgeschlagen"), "got: {body}"); } #[tokio::test] async fn login_unknown_user_shows_error() { - let config = test_config_with_users(vec![make_user("dr_a", "secret")]); - let app = doctate_server::create_router(config); + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "secret")) + .build(); + let app = doctate_server::create_router(cfg); let resp = app .oneshot(login_request("ghost", "anything")) @@ -124,13 +71,15 @@ async fn login_unknown_user_shows_error() { #[tokio::test] async fn cases_without_cookie_redirects_to_login() { - let config = test_config_with_users(vec![make_user("dr_a", "secret")]); - let app = doctate_server::create_router(config); + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "secret")) + .build(); + let app = doctate_server::create_router(cfg); let resp = app .oneshot( Request::builder() - .uri("/web/cases") + .uri(paths::CASES) .body(Body::empty()) .unwrap(), ) @@ -143,18 +92,21 @@ async fn cases_without_cookie_redirects_to_login() { .unwrap() .to_str() .unwrap(), - "/web/login" + paths::LOGIN ); } #[tokio::test] async fn cases_with_valid_cookie_shows_only_own_cases() { - let config = test_config_with_users(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]); + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "s")) + .with_user(test_user_with_password("dr_b", "s")) + .build(); // Seed fixtures: dr_a has an open case, dr_b has one too. - let case_a = config + let case_a = cfg .data_path .join("dr_a/11111111-1111-1111-1111-111111111111"); - let case_b = config + let case_b = cfg .data_path .join("dr_b/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_a).unwrap(); @@ -162,58 +114,39 @@ async fn cases_with_valid_cookie_shows_only_own_cases() { std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); - let app = doctate_server::create_router(config); - - // Log in as dr_a. - let login = app - .clone() - .oneshot(login_request("dr_a", "s")) - .await - .unwrap(); - let cookie = extract_session_cookie(&login).unwrap(); + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; let resp = app - .oneshot( - Request::builder() - .uri("/web/cases") - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie(paths::CASES, &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = body_to_string(resp).await; + let body = body_string(resp).await; assert!(body.contains("11111111"), "own case missing: {body}"); assert!(!body.contains("22222222"), "foreign case leaked: {body}"); } #[tokio::test] async fn case_detail_of_foreign_case_returns_404() { - let config = test_config_with_users(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]); - let case_b = config + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "s")) + .with_user(test_user_with_password("dr_b", "s")) + .build(); + let case_b = cfg .data_path .join("dr_b/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); - let app = doctate_server::create_router(config); - - let login = app - .clone() - .oneshot(login_request("dr_a", "s")) - .await - .unwrap(); - let cookie = extract_session_cookie(&login).unwrap(); + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; let resp = app - .oneshot( - Request::builder() - .uri("/web/cases/22222222-2222-2222-2222-222222222222") - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie( + paths::case_detail("22222222-2222-2222-2222-222222222222"), + &cookie, + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -221,42 +154,23 @@ async fn case_detail_of_foreign_case_returns_404() { #[tokio::test] async fn logout_clears_session() { - let config = test_config_with_users(vec![make_user("dr_a", "s")]); - let (app, store) = doctate_server::create_router_and_session_store(config); + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_a", "s")) + .build(); + let (app, store) = doctate_server::create_router_and_session_store(cfg); - let login = app - .clone() - .oneshot(login_request("dr_a", "s")) - .await - .unwrap(); - let cookie = extract_session_cookie(&login).unwrap(); - let token = cookie.trim_start_matches("session="); - let csrf = store.read().await.get(token).unwrap().csrf_token.clone(); + let (cookie, csrf) = common::login_with_csrf(&app, &store, "dr_a", "s").await; let logout = app .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/web/logout") - .header(header::COOKIE, &cookie) - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("csrf_token={csrf}"))) - .unwrap(), - ) + .oneshot(common::csrf_form_post(paths::LOGOUT, &cookie, &csrf, "")) .await .unwrap(); assert_eq!(logout.status(), StatusCode::SEE_OTHER.as_u16()); // Same cookie must no longer be accepted. let resp = app - .oneshot( - Request::builder() - .uri("/web/cases") - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie(paths::CASES, &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs index 2f7dc2c..7af3591 100644 --- a/server/tests/magic_link_test.rs +++ b/server/tests/magic_link_test.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +mod common; + use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::{Duration, Instant}; @@ -9,65 +10,15 @@ use serde_json::json; use tower::util::ServiceExt; use doctate_common::API_KEY_HEADER; -use doctate_server::config::{Config, User}; use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink}; use doctate_server::{ AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session, }; +use common::{TestConfig, body_json, test_user}; + const API_KEY: &str = "key-dr_a"; -fn make_user() -> User { - User { - slug: "dr_a".into(), - api_key: API_KEY.into(), - web_password: bcrypt::hash("unused", 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } -} - -fn build_state() -> (AppState, Arc) { - let user = make_user(); - let data_path = std::env::temp_dir().join(format!( - "doctate-magic-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - let mut api_keys: HashMap = HashMap::new(); - api_keys.insert(user.api_key.clone(), user.slug.clone()); - - let config = Arc::new(Config { - data_path, - users: vec![user], - api_keys, - ..Config::test_default() - }); - - let (transcribe_tx, _transcribe_rx) = transcribe::channel(); - let (analyze_tx, _analyze_rx) = analyze::channel(); - let magic_link_store = doctate_server::magic_link::new_store(); - - let state = AppState { - config: config.clone(), - transcribe_tx, - analyze_tx, - session_store: web_session::new_store(), - magic_link_store, - analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), - transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), - oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))), - events_tx: doctate_server::events::channel(), - http_client: reqwest::Client::new(), - vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()), - }; - - (state, config) -} - fn create_request(body: &str, api_key: Option<&str>) -> Request { let mut b = Request::builder() .method("POST") @@ -82,32 +33,55 @@ fn create_request(body: &str, api_key: Option<&str>) -> Request { fn consume_request(token: &str) -> Request { Request::builder() .method("GET") - .uri(format!("/web/magic?token={token}")) + .uri(common::paths::magic(token)) .body(Body::empty()) .unwrap() } -async fn body_json(response: axum::response::Response) -> serde_json::Value { - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() +/// Most tests only need a router. Only the expiry test keeps a handle +/// to the `MagicLinkStore` so it can insert a pre-expired token. +fn test_app() -> axum::Router { + let cfg = TestConfig::new() + .with_label("magic") + .with_user(test_user("dr_a")) + .build(); + doctate_server::create_router(cfg) +} + +/// For the expiry test: build the full `AppState` so we retain a +/// reference to `magic_link_store` after the router is constructed. +fn build_state_with_stores() -> AppState { + let cfg = TestConfig::new() + .with_label("magic") + .with_user(test_user("dr_a")) + .build(); + let (transcribe_tx, _transcribe_rx) = transcribe::channel(); + let (analyze_tx, _analyze_rx) = analyze::channel(); + AppState { + config: cfg, + transcribe_tx, + analyze_tx, + session_store: web_session::new_store(), + magic_link_store: doctate_server::magic_link::new_store(), + analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), + transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), + oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))), + events_tx: doctate_server::events::channel(), + http_client: reqwest::Client::new(), + vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()), + } } #[tokio::test] async fn create_without_api_key_returns_401() { - let (state, _) = build_state(); - let app = doctate_server::create_router_with_state(state); - + let app = test_app(); let resp = app.oneshot(create_request("{}", None)).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_with_valid_key_returns_token() { - let (state, _) = build_state(); - let app = doctate_server::create_router_with_state(state); - + let app = test_app(); let resp = app .oneshot(create_request( r#"{"return_to":"/web/cases/abc"}"#, @@ -124,8 +98,7 @@ async fn create_with_valid_key_returns_token() { #[tokio::test] async fn consume_valid_token_sets_cookie_and_redirects() { - let (state, _) = build_state(); - let app = doctate_server::create_router_with_state(state); + let app = test_app(); // Step 1: issue token via API. let create_resp = app @@ -175,8 +148,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() { #[tokio::test] async fn consume_token_is_one_time_use() { - let (state, _) = build_state(); - let app = doctate_server::create_router_with_state(state); + let app = test_app(); let token = body_json( app.clone() @@ -207,8 +179,7 @@ async fn consume_token_is_one_time_use() { #[tokio::test] async fn create_rejects_open_redirect_return_to() { - let (state, _) = build_state(); - let app = doctate_server::create_router_with_state(state); + let app = test_app(); for evil in [ "https://evil.com/", @@ -232,7 +203,7 @@ async fn create_rejects_open_redirect_return_to() { #[tokio::test] async fn consume_expired_token_redirects_to_login() { - let (state, _) = build_state(); + let state = build_state_with_stores(); let store: MagicLinkStore = state.magic_link_store.clone(); // Insert a token that expired 1s ago — bypassing the API to control time. diff --git a/server/tests/retention_sweep_test.rs b/server/tests/retention_sweep_test.rs index 60a1950..2fc108b 100644 --- a/server/tests/retention_sweep_test.rs +++ b/server/tests/retention_sweep_test.rs @@ -4,138 +4,52 @@ //! (m4a mtime, closed_at string) instead of faking "now" — gives //! realistic behavioural coverage without a clock abstraction. -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; +mod common; -use axum::body::Body; -use axum::http::{Request, StatusCode, header}; -use filetime::FileTime; +use std::time::Duration; + +use axum::http::StatusCode; use tower::util::ServiceExt; -use doctate_server::config::{Config, RetentionUserSettings, User}; - -fn unique_tmp(label: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "doctate-retention-{label}-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )) -} - -fn user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash("s", 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: RetentionUserSettings { - auto_close_days, - auto_delete_days, - }, - window_hours: 72, - preview_lines: 2, - } -} - -fn config_with(data_path: PathBuf, auto_close_days: u32, auto_delete_days: u32) -> Arc { - let users = vec![user_with_retention( - "dr_a", - auto_close_days, - auto_delete_days, - )]; - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - ..Config::test_default() - }) -} - -fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { - let dir = data_path.join(slug).join(case_id); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -/// Create an m4a with a specific mtime (used to simulate "recorded N -/// days ago"). The content is meaningless; only the mtime matters. -fn seed_recording_with_age(case_dir: &Path, ts_hms: &str, age: Duration) { - let filename = format!("2026-04-01T{ts_hms}Z.m4a"); - let path = case_dir.join(&filename); - std::fs::write(&path, b"audio").unwrap(); - let target = SystemTime::now() - age; - filetime::set_file_mtime(&path, FileTime::from_system_time(target)).unwrap(); -} - -/// Write a `.closed` marker with a specific `closed_at` string (age -/// is simulated by handing in a historical RFC3339 stamp). -fn write_closed_marker(case_dir: &Path, closed_at: &str) { - let json = format!(r#"{{"closed_at":"{closed_at}"}}"#); - std::fs::write(case_dir.join(".closed"), json).unwrap(); -} - -fn past_rfc3339(age: Duration) -> String { - use time::format_description::well_known::Rfc3339; - let t = time::OffsetDateTime::now_utc() - time::Duration::seconds(age.as_secs() as i64); - t.format(&Rfc3339).unwrap() -} - -async fn login(app: axum::Router, slug: &str) -> String { - let body = format!("slug={slug}&password=s"); - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/login") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - for v in resp.headers().get_all(header::SET_COOKIE).iter() { - let s = v.to_str().unwrap(); - if let Some(pair) = s.split(';').next() - && pair.starts_with("session=") - { - return pair.to_string(); - } - } - panic!("no session cookie after login"); -} +use common::{ + TestConfig, get_with_cookie, past_rfc3339, paths, seed_case, seed_recording_with_age, + test_user_with_retention, write_closed_marker, +}; /// Trigger the sweep via its natural path: GET /web/cases. async fn trigger_sweep(app: &axum::Router, cookie: &str) { let resp = app .clone() - .oneshot( - Request::builder() - .uri("/web/cases") - .header(header::COOKIE, cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie(paths::CASES, cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } +fn build_cfg(label: &'static str, auto_close_days: u32, auto_delete_days: u32) -> std::sync::Arc { + TestConfig::new() + .with_label(label) + .with_user(test_user_with_retention( + "dr_a", + auto_close_days, + auto_delete_days, + )) + .build() +} + #[tokio::test] async fn auto_close_fires_when_last_recording_too_old() { - let config = config_with(unique_tmp("close-old"), 7, 0); + let cfg = build_cfg("close-old", 7, 0); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(8 * 86400)); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(8 * 86400), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; assert!(!case_dir.join(".closed").exists()); trigger_sweep(&app, &cookie).await; @@ -149,13 +63,17 @@ async fn auto_close_fires_when_last_recording_too_old() { async fn auto_close_disabled_when_days_zero() { // auto_close_days = 0 → sweep never closes for age; even a very // old recording must stay open. - let config = config_with(unique_tmp("close-zero"), 0, 0); + let cfg = build_cfg("close-zero", 0, 0); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(365 * 86400)); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(365 * 86400), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; trigger_sweep(&app, &cookie).await; assert!( @@ -167,14 +85,22 @@ async fn auto_close_disabled_when_days_zero() { #[tokio::test] async fn auto_close_respects_newest_recording() { // Case has one very old .m4a and one fresh .m4a — must stay open. - let config = config_with(unique_tmp("close-newest"), 7, 0); + let cfg = build_cfg("close-newest", 7, 0); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(30 * 86400)); - seed_recording_with_age(&case_dir, "11-00-00", Duration::from_secs(60)); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(30 * 86400), + ); + seed_recording_with_age( + &case_dir, + "2026-04-01T11-00-00Z", + Duration::from_secs(60), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; trigger_sweep(&app, &cookie).await; assert!( @@ -185,15 +111,22 @@ async fn auto_close_respects_newest_recording() { #[tokio::test] async fn auto_purge_removes_old_closed_case() { - let config = config_with(unique_tmp("purge-old"), 0, 30); + let cfg = build_cfg("purge-old", 0, 30); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(60)); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(60), + ); // closed_at = 31 days ago → must be purged. - write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(31 * 86400))); + write_closed_marker( + &case_dir, + &past_rfc3339(Duration::from_secs(31 * 86400)), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; trigger_sweep(&app, &cookie).await; assert!( @@ -206,14 +139,21 @@ async fn auto_purge_removes_old_closed_case() { async fn auto_purge_disabled_when_days_zero() { // Admin-test guarantee: auto_delete_days = 0 means closed cases // are retained indefinitely. - let config = config_with(unique_tmp("purge-zero"), 0, 0); + let cfg = build_cfg("purge-zero", 0, 0); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(60)); - write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(365 * 86400))); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(60), + ); + write_closed_marker( + &case_dir, + &past_rfc3339(Duration::from_secs(365 * 86400)), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; trigger_sweep(&app, &cookie).await; assert!( @@ -230,13 +170,17 @@ async fn fresh_auto_close_is_not_immediately_purged() { // The sweep auto-closes (step 1, closed_at ≈ now), but the purge // check (step 2) sees closed_at ≈ now and skips — the user gets // the full 30-day grace period, nothing disappears silently. - let config = config_with(unique_tmp("vacation"), 7, 30); + let cfg = build_cfg("vacation", 7, 30); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(40 * 86400)); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(40 * 86400), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; trigger_sweep(&app, &cookie).await; assert!( @@ -251,13 +195,17 @@ async fn fresh_auto_close_is_not_immediately_purged() { #[tokio::test] async fn auto_purge_skips_open_cases() { - let config = config_with(unique_tmp("purge-skip-open"), 0, 30); + let cfg = build_cfg("purge-skip-open", 0, 30); let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(60)); + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording_with_age( + &case_dir, + "2026-04-01T10-00-00Z", + Duration::from_secs(60), + ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let app = doctate_server::create_router(cfg); + let cookie = common::login(&app, "dr_a", "s").await; trigger_sweep(&app, &cookie).await; assert!( diff --git a/server/tests/security_headers_test.rs b/server/tests/security_headers_test.rs index 6199e0a..9d88883 100644 --- a/server/tests/security_headers_test.rs +++ b/server/tests/security_headers_test.rs @@ -5,93 +5,65 @@ //! The layer is composed in `doctate_server::with_security_headers`, //! applied to every response by `create_router_with_state`. -use std::collections::HashMap; -use std::sync::Arc; +mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; +use axum::response::Response; use tower::util::ServiceExt; -use doctate_server::config::{Config, User}; +use common::{TestConfig, header_opt, test_user_with_password}; -// ---------- fixtures ---------- - -fn test_config() -> Arc { - Arc::new(Config { - data_path: std::env::temp_dir().join(format!( - "doctate-sechdr-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )), - users: vec![User { - slug: "dr_test".into(), - api_key: "test-key-123".into(), - web_password: bcrypt::hash("secret", 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - }], - api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), - ..Config::test_default() - }) +fn test_app() -> axum::Router { + let cfg = TestConfig::new() + .with_user(test_user_with_password("dr_test", "secret")) + .build(); + doctate_server::create_router(cfg) } -fn header_value<'a>(resp: &'a axum::response::Response, name: &str) -> Option<&'a str> { - resp.headers().get(name).and_then(|v| v.to_str().ok()) -} - -fn count_header_values(resp: &axum::response::Response, name: &str) -> usize { +fn count_header_values(resp: &Response, name: &str) -> usize { resp.headers().get_all(name).iter().count() } +async fn get(app: axum::Router, uri: &str) -> Response { + app.oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() +} + // ---------- presence tests ---------- #[tokio::test] async fn api_health_has_all_security_headers() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let resp = get(test_app(), "/api/health").await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( - header_value(&resp, "x-content-type-options"), + header_opt(&resp, "x-content-type-options"), Some("nosniff") ); - assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); - assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer")); + assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); + assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer")); assert!( - header_value(&resp, "content-security-policy").is_some(), + header_opt(&resp, "content-security-policy").is_some(), "CSP header missing on /api/health" ); assert!( - header_value(&resp, "permissions-policy").is_some(), + header_opt(&resp, "permissions-policy").is_some(), "Permissions-Policy missing on /api/health" ); } #[tokio::test] async fn web_login_page_has_all_security_headers() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/web/login") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let resp = get(test_app(), "/web/login").await; assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); - assert!(header_value(&resp, "content-security-policy").is_some()); + assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); + assert!(header_opt(&resp, "content-security-policy").is_some()); } // ---------- per-attack tests ---------- @@ -101,18 +73,9 @@ async fn web_login_page_has_all_security_headers() { /// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`. #[tokio::test] async fn csp_blocks_clickjacking_via_frame_ancestors() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); - let csp = header_value(&resp, "content-security-policy").unwrap_or(""); + let resp = get(test_app(), "/api/health").await; + assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); + let csp = header_opt(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("frame-ancestors 'none'"), "CSP missing frame-ancestors: {csp}" @@ -123,17 +86,8 @@ async fn csp_blocks_clickjacking_via_frame_ancestors() { /// Defense: CSP `object-src 'none'`. #[tokio::test] async fn csp_blocks_object_embed_plugins() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let csp = header_value(&resp, "content-security-policy").unwrap_or(""); + let resp = get(test_app(), "/api/health").await; + let csp = header_opt(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("object-src 'none'"), "CSP missing object-src 'none': {csp}" @@ -144,17 +98,8 @@ async fn csp_blocks_object_embed_plugins() { /// Defense: CSP `base-uri 'self'`. #[tokio::test] async fn csp_blocks_base_uri_hijack() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let csp = header_value(&resp, "content-security-policy").unwrap_or(""); + let resp = get(test_app(), "/api/health").await; + let csp = header_opt(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("base-uri 'self'"), "CSP missing base-uri 'self': {csp}" @@ -165,17 +110,8 @@ async fn csp_blocks_base_uri_hijack() { /// Defense: CSP `form-action 'self'`. #[tokio::test] async fn csp_blocks_form_action_hijack() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let csp = header_value(&resp, "content-security-policy").unwrap_or(""); + let resp = get(test_app(), "/api/health").await; + let csp = header_opt(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("form-action 'self'"), "CSP missing form-action 'self': {csp}" @@ -186,18 +122,9 @@ async fn csp_blocks_form_action_hijack() { /// executed. Defense: `X-Content-Type-Options: nosniff`. #[tokio::test] async fn nosniff_blocks_mime_confusion() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let resp = get(test_app(), "/api/health").await; assert_eq!( - header_value(&resp, "x-content-type-options"), + header_opt(&resp, "x-content-type-options"), Some("nosniff") ); } @@ -206,34 +133,16 @@ async fn nosniff_blocks_mime_confusion() { /// Defense: `Referrer-Policy: no-referrer`. #[tokio::test] async fn referrer_policy_prevents_leak() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer")); + let resp = get(test_app(), "/api/health").await; + assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer")); } /// Malicious script requests microphone/camera access in background. /// Defense: `Permissions-Policy` explicitly denies those features. #[tokio::test] async fn permissions_policy_blocks_sensitive_features() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let pp = header_value(&resp, "permissions-policy").unwrap_or(""); + let resp = get(test_app(), "/api/health").await; + let pp = header_opt(&resp, "permissions-policy").unwrap_or(""); for feat in ["microphone", "camera", "geolocation", "payment"] { assert!( pp.contains(&format!("{feat}=()")), @@ -249,25 +158,16 @@ async fn permissions_policy_blocks_sensitive_features() { /// short-circuit on error paths. #[tokio::test] async fn error_redirect_still_carries_security_headers() { - let app = doctate_server::create_router(test_config()); // /web/cases without cookie → 302 redirect (error path from the // session extractor). - let resp = app - .oneshot( - Request::builder() - .uri("/web/cases") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let resp = get(test_app(), "/web/cases").await; assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!( - header_value(&resp, "x-content-type-options"), + header_opt(&resp, "x-content-type-options"), Some("nosniff") ); - assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); - assert!(header_value(&resp, "content-security-policy").is_some()); + assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); + assert!(header_opt(&resp, "content-security-policy").is_some()); } /// `magic.rs` already sets `Referrer-Policy: no-referrer` on the magic @@ -276,22 +176,13 @@ async fn error_redirect_still_carries_security_headers() { /// yet) and must keep passing after the layer lands. #[tokio::test] async fn magic_route_referrer_policy_not_duplicated() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/web/magic?token=definitely-invalid") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let resp = get(test_app(), "/web/magic?token=definitely-invalid").await; assert_eq!( count_header_values(&resp, "referrer-policy"), 1, "Referrer-Policy appeared more than once — header layer should be if_not_present" ); - assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer")); + assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer")); } /// HSTS must NOT be set until TLS is actually terminated in front of the @@ -299,16 +190,7 @@ async fn magic_route_referrer_policy_not_duplicated() { /// (max-age cache). Regression guard against accidental HSTS additions. #[tokio::test] async fn no_hsts_header_until_tls_is_in_place() { - let app = doctate_server::create_router(test_config()); - let resp = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let resp = get(test_app(), "/api/health").await; assert!( resp.headers().get("strict-transport-security").is_none(), "HSTS set but no TLS termination is in place — would break HTTP deployments"