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.
This commit is contained in:
+142
-105
@@ -92,7 +92,11 @@ fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(app: axum::Router, slug: &str) -> String {
|
||||
async fn login(
|
||||
app: axum::Router,
|
||||
store: &doctate_server::web_session::SessionStore,
|
||||
slug: &str,
|
||||
) -> (String, String) {
|
||||
let body = format!("slug={slug}&password=s");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
@@ -110,41 +114,56 @@ async fn login(app: axum::Router, slug: &str) -> String {
|
||||
if let Some(pair) = s.split(';').next()
|
||||
&& pair.starts_with("session=")
|
||||
{
|
||||
return pair.to_string();
|
||||
let cookie = pair.to_string();
|
||||
let csrf = store
|
||||
.read()
|
||||
.await
|
||||
.get(cookie.trim_start_matches("session="))
|
||||
.expect("session must be in store after login")
|
||||
.csrf_token
|
||||
.clone();
|
||||
return (cookie, csrf);
|
||||
}
|
||||
}
|
||||
panic!("no session cookie after login");
|
||||
}
|
||||
|
||||
fn analyze_request(case_id: &str, cookie: &str) -> Request<Body> {
|
||||
fn analyze_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/web/cases/{case_id}/analyze"))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn close_request(case_id: &str, cookie: &str) -> Request<Body> {
|
||||
fn close_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/web/cases/{case_id}/close"))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn reopen_request(case_id: &str, cookie: &str) -> Request<Body> {
|
||||
fn reopen_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/web/cases/{case_id}/reopen"))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn purge_closed_request(cookie: &str, confirm: Option<&str>) -> Request<Body> {
|
||||
let body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default();
|
||||
fn purge_closed_request(cookie: &str, csrf: &str, confirm: Option<&str>) -> Request<Body> {
|
||||
let mut body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default();
|
||||
if !body.is_empty() {
|
||||
body.push('&');
|
||||
}
|
||||
body.push_str(&format!("csrf_token={csrf}"));
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/web/cases/purge-closed")
|
||||
@@ -185,11 +204,11 @@ async fn analyze_foreign_case_returns_404() {
|
||||
std::fs::create_dir_all(&foreign_dir).unwrap();
|
||||
seed_recording(&foreign_dir, "10-00-00", Some("text"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(foreign_case, &cookie))
|
||||
.oneshot(analyze_request(foreign_case, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
@@ -198,11 +217,11 @@ async fn analyze_foreign_case_returns_404() {
|
||||
#[tokio::test]
|
||||
async fn analyze_invalid_uuid_returns_400() {
|
||||
let config = config_with_llm(unique_tmp("c"), "http://unused".into());
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request("not-a-uuid", &cookie))
|
||||
.oneshot(analyze_request("not-a-uuid", &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
@@ -215,11 +234,11 @@ async fn analyze_case_without_recordings_returns_400() {
|
||||
seed_case(&config.data_path, "dr_a", case_id);
|
||||
// No .m4a files.
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
@@ -233,11 +252,11 @@ async fn analyze_case_with_missing_transcript_returns_400() {
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
seed_recording(&case_dir, "10-05-00", None); // still transcribing
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
@@ -250,11 +269,11 @@ async fn analyze_case_without_llm_returns_503() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
@@ -276,11 +295,11 @@ async fn analyze_case_happy_path_writes_input_and_redirects() {
|
||||
Some("Korrektur: links, nicht rechts."),
|
||||
);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -313,18 +332,18 @@ async fn analyze_case_second_time_returns_409() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("text"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
let second = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
||||
@@ -339,11 +358,11 @@ async fn analyze_case_skips_blank_transcript_from_recordings() {
|
||||
seed_recording(&case_dir, "10-05-00", Some(" ")); // blank
|
||||
seed_recording(&case_dir, "10-10-00", Some("auch echt"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -607,11 +626,11 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() {
|
||||
seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme"));
|
||||
seed_recording(&case_dir, "10-05-00", Some("zweite Aufnahme"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(analyze_request(case_id, &cookie))
|
||||
.oneshot(analyze_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -638,10 +657,13 @@ async fn close_writes_marker_and_hides_case() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
@@ -668,12 +690,12 @@ async fn closed_case_returns_404_on_detail() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -698,20 +720,20 @@ async fn reopen_removes_marker_and_restores_case() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
// Close, then reopen via the explicit endpoint.
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(case_dir.join(".closed").exists());
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(reopen_request(case_id, &cookie))
|
||||
.oneshot(reopen_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -741,13 +763,13 @@ async fn show_closed_query_includes_closed_cases() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("a closed one"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
// Close first.
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -814,12 +836,12 @@ async fn closed_case_detail_with_show_closed_returns_200() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("x"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -862,8 +884,8 @@ async fn close_redirects_back_to_referer_query() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
@@ -872,7 +894,8 @@ async fn close_redirects_back_to_referer_query() {
|
||||
.uri(format!("/web/cases/{case_id}/close"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::REFERER, "/web/cases?show_closed=1")
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -898,8 +921,8 @@ async fn close_from_detail_redirects_to_list_preserving_query() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
@@ -911,7 +934,8 @@ async fn close_from_detail_redirects_to_list_preserving_query() {
|
||||
header::REFERER,
|
||||
format!("/web/cases/{case_id}?show_closed=1"),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -938,12 +962,12 @@ async fn reopen_redirects_back_to_referer() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -954,7 +978,8 @@ async fn reopen_redirects_back_to_referer() {
|
||||
.uri(format!("/web/cases/{case_id}/reopen"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::REFERER, "/web/cases?show_closed=1")
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -978,10 +1003,13 @@ async fn reopen_is_noop_on_open_case() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app.oneshot(reopen_request(case_id, &cookie)).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(reopen_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert!(!case_dir.join(".closed").exists());
|
||||
}
|
||||
@@ -998,18 +1026,18 @@ async fn purge_closed_removes_directory() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(case_dir.join(".closed").exists());
|
||||
|
||||
let resp = app
|
||||
.oneshot(purge_closed_request(&cookie, Some("yes")))
|
||||
.oneshot(purge_closed_request(&cookie, &csrf, Some("yes")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -1030,17 +1058,17 @@ async fn purge_requires_confirm_param() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(purge_closed_request(&cookie, None))
|
||||
.oneshot(purge_closed_request(&cookie, &csrf, None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
@@ -1065,17 +1093,17 @@ async fn purge_closed_keeps_open_cases() {
|
||||
seed_recording(&dir_closed, "10-00-00", Some("a"));
|
||||
seed_recording(&dir_open, "10-00-00", Some("b"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(closed_id, &cookie))
|
||||
.oneshot(close_request(closed_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(purge_closed_request(&cookie, Some("yes")))
|
||||
.oneshot(purge_closed_request(&cookie, &csrf, Some("yes")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -1094,18 +1122,18 @@ async fn purge_closed_rejected_for_non_admin() {
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
// Close the case first so there is something for purge to target.
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(close_request(case_id, &cookie))
|
||||
.oneshot(close_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(purge_closed_request(&cookie, Some("yes")))
|
||||
.oneshot(purge_closed_request(&cookie, &csrf, Some("yes")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
@@ -1128,10 +1156,10 @@ async fn bulk_close_shares_one_closed_at_timestamp() {
|
||||
seed_recording(&dir_a, "10-00-00", Some("a"));
|
||||
seed_recording(&dir_b, "10-05-00", Some("b"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let body = format!("action=close&case_id={case_a}&case_id={case_b}");
|
||||
let body = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1171,10 +1199,10 @@ async fn bulk_analyze_processes_each_selected_case() {
|
||||
seed_recording(&dir_a, "10-00-00", Some("a"));
|
||||
seed_recording(&dir_b, "10-05-00", Some("b"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let body = format!("action=analyze&case_id={case_a}&case_id={case_b}");
|
||||
let body = format!("action=analyze&case_id={case_a}&case_id={case_b}&csrf_token={csrf}");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1215,12 +1243,13 @@ async fn recovery_skips_closed_cases() {
|
||||
// Admin-only reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn reset_request(case_id: &str, cookie: &str) -> Request<Body> {
|
||||
fn reset_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/web/cases/{case_id}/reset"))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -1248,10 +1277,13 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
||||
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
||||
std::fs::write(dir.join("analysis_input.json"), "{}").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(reset_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/cases");
|
||||
|
||||
@@ -1276,10 +1308,13 @@ async fn reset_case_requires_admin() {
|
||||
seed_recording(&dir, "09-00-00", Some("hallo"));
|
||||
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(reset_request(case_id, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// Nothing touched.
|
||||
@@ -1309,11 +1344,11 @@ async fn bulk_reset_processes_multiple_cases_admin_only() {
|
||||
seed_recording(&dir_c, "11-00-00", Some("c"));
|
||||
std::fs::write(dir_c.join("document.md"), "C").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
|
||||
// Admin: bulk reset succeeds.
|
||||
let cookie_admin = login(app.clone(), "dr_a").await;
|
||||
let body = format!("action=reset&case_id={case_a}&case_id={case_b}");
|
||||
let (cookie_admin, csrf_admin) = login(app.clone(), &store, "dr_a").await;
|
||||
let body = format!("action=reset&case_id={case_a}&case_id={case_b}&csrf_token={csrf_admin}");
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -1334,8 +1369,8 @@ async fn bulk_reset_processes_multiple_cases_admin_only() {
|
||||
assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists());
|
||||
|
||||
// Non-admin: bulk reset is rejected, dr_b's case untouched.
|
||||
let cookie_doctor = login(app.clone(), "dr_b").await;
|
||||
let body = format!("action=reset&case_id={case_c}");
|
||||
let (cookie_doctor, csrf_doctor) = login(app.clone(), &store, "dr_b").await;
|
||||
let body = format!("action=reset&case_id={case_c}&csrf_token={csrf_doctor}");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1366,10 +1401,12 @@ async fn bulk_close_rejected_for_non_admin() {
|
||||
let dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_recording(&dir, "10-00-00", Some("a"));
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
let (cookie, csrf) = login(app.clone(), &store, "dr_a").await;
|
||||
|
||||
let body = format!("action=close&case_id={case_id}");
|
||||
// Include csrf_token so the admin check is the gate that fires,
|
||||
// not the CSRF check — this test verifies the admin gate, not CSRF.
|
||||
let body = format!("action=close&case_id={case_id}&csrf_token={csrf}");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
||||
Reference in New Issue
Block a user