Files
doctate/server/tests/delete_recording_test.rs
T
Brummel bf3e9ba6cc feat: server-side CSRF token validation
Introduces CsrfForm<T>: a FromRequest extractor that looks up the
session's csrf_token and constant-time-compares it to a csrf_token
field in the form body. Drop-in replacement for Form<T> on every
state-changing /web/ POST handler. Missing or expired session →
redirect to /web/login; malformed body → 400; token mismatch → 403.

WebSession carries csrf_token, minted alongside the session token at
login and magic-link consume. Not rotated per request — rotation
would break multi-tab use and buys little over SameSite=Strict
cookies.

Handlers: bulk, purge-closed, reset, close, reopen, analyze,
delete-recording, logout all now require CsrfForm<_>. Login stays
unprotected (SameSite=Strict alone is sufficient — forced-login CSRF
has no impact on this codebase). /api/... is header-auth, exempt.

Constant-time compare via subtle::ConstantTimeEq avoids timing
oracles on the token.

Template work is NOT in this commit — browser forms still post
without csrf_token, so the live web UI will 403 until Schritt 6
(templates render the hidden field).

Tests: all 12 red specs in csrf_attack_test.rs now green, #[ignore]
removed. Integration tests that POSTed to protected endpoints
switched to a new create_router_and_session_store entrypoint which
returns a handle to the store so tests can read csrf_token for the
active session.
2026-04-22 09:59:17 +02:00

328 lines
11 KiB
Rust

//! Per-recording delete endpoint: POST /web/cases/{case_id}/recordings/delete.
//!
//! Verifies the endpoint:
//! - removes the audio file and its transcript / duration sidecars
//! - invalidates derived artefacts (oneliner.json, document.md, analysis_input.json)
//! - leaves other recordings in the same case untouched
//! - 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;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use doctate_server::config::{Config, User};
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 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()
}
#[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 case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case_dir(&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");
// 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 resp = app
.clone()
.oneshot(delete_request(case_id, &victim, &cookie, &csrf))
.await
.unwrap();
assert_eq!(
resp.status().as_u16() / 100,
3,
"delete should redirect (3xx), got {}",
resp.status()
);
// Victim + its sidecars are gone.
assert!(!case_dir.join(&victim).exists(), "m4a should be deleted");
assert!(
!case_dir
.join("2026-04-19T10-00-00Z.transcript.txt")
.exists(),
"transcript sidecar should be deleted"
);
assert!(
!case_dir.join("2026-04-19T10-00-00Z.duration.txt").exists(),
"duration sidecar should be deleted"
);
// Survivor is intact.
assert!(case_dir.join(&survivor).exists(), "other m4a must remain");
assert!(
case_dir
.join("2026-04-19T11-00-00Z.transcript.txt")
.exists(),
"other transcript must remain"
);
// Derived artefacts are invalidated.
assert!(
!case_dir.join("oneliner.json").exists(),
"oneliner.json must be cleared"
);
assert!(
!case_dir.join("document.md").exists(),
"document.md must be cleared"
);
assert!(
!case_dir.join("analysis_input.json").exists(),
"analysis_input.json must be cleared"
);
}
#[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 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 (app, store) = doctate_server::create_router_and_session_store(config);
let (cookie, csrf) = login(&app, &store, "dr_a", "s").await;
let resp = app
.clone()
.oneshot(delete_request(
case_id,
"../../../etc/passwd.m4a",
&cookie,
&csrf,
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"path traversal must be rejected"
);
// Safe recording is untouched.
assert!(case_dir.join("2026-04-19T10-00-00Z.m4a").exists());
}
#[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 case_id = "33333333-3333-3333-3333-333333333333";
seed_case_dir(&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 resp = app
.clone()
.oneshot(delete_request(case_id, "document.md", &cookie, &csrf))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"non-audio extension must be rejected"
);
}
#[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 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 (app, store) = doctate_server::create_router_and_session_store(config);
let (cookie_a, csrf_a) = login(&app, &store, "dr_a", "s").await;
let resp = app
.clone()
.oneshot(delete_request(case_id, &filename, &cookie_a, &csrf_a))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"cross-user access must return 404, not 403 (IDOR hardening)"
);
assert!(
case_dir.join(&filename).exists(),
"Bob's file must not have been touched"
);
}
#[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 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");
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 resp = app
.clone()
.oneshot(delete_request(case_id, &only, &cookie, &csrf))
.await
.unwrap();
assert_eq!(
resp.status().as_u16() / 100,
3,
"last-recording delete should still redirect, got {}",
resp.status()
);
assert!(!case_dir.join(&only).exists());
assert!(!case_dir.join("oneliner.json").exists());
}
#[tokio::test]
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 case_id = "66666666-6666-6666-6666-666666666666";
let case_dir = seed_case_dir(&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 resp = app
.clone()
.oneshot(delete_request(
case_id,
"2026-04-19T10-00-00Z.m4a.failed",
&cookie,
&csrf,
))
.await
.unwrap();
assert_eq!(resp.status().as_u16() / 100, 3);
assert!(!case_dir.join("2026-04-19T10-00-00Z.m4a.failed").exists());
}