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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user