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:
+450
-578
File diff suppressed because it is too large
Load Diff
+13
-31
@@ -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(
|
||||
|
||||
+213
-238
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
use common::{
|
||||
TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready,
|
||||
seed_oneliner_state, seed_recording, test_admin, test_user,
|
||||
};
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Session cookie extraction + login flows.
|
||||
//!
|
||||
//! These were previously copy-pasted 4–6 times across the integration
|
||||
//! tests. Centralising them here means: a change to the cookie format
|
||||
//! or the login form field names affects one file, not six.
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, header};
|
||||
use axum::response::Response;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::web_session::SessionStore;
|
||||
|
||||
use crate::common::paths;
|
||||
|
||||
/// Extract the freshly-minted `session=<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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user