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

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

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

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