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:
Generated
+1
@@ -1219,6 +1219,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"spellbook",
|
||||
"strsim",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
|
||||
@@ -31,6 +31,7 @@ rpassword = "7.4.0"
|
||||
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||
strsim = "0.11"
|
||||
spellbook = "0.4.0"
|
||||
subtle = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! CSRF-token validation for state-changing POSTs under `/web/`.
|
||||
//!
|
||||
//! The `WebSession` carries a `csrf_token` minted once at login or
|
||||
//! magic-link consume. Browser forms include a hidden `csrf_token`
|
||||
//! field populated from the session. Handlers that mutate server
|
||||
//! state use [`CsrfForm<T>`] instead of `Form<T>` to enforce that the
|
||||
//! submitted token matches the session's token. The session cookie
|
||||
//! alone is not sufficient: `SameSite=Strict` already blocks most
|
||||
//! cross-site POSTs, but the token is the defence that also holds
|
||||
//! against attackers with cross-site injection on a same-site
|
||||
//! subdomain or misconfigured browser.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::extract::{FromRef, FromRequest, Request};
|
||||
use axum_extra::extract::{CookieJar, Form};
|
||||
use serde::de::DeserializeOwned;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::web_session::{SESSION_COOKIE, SessionStore};
|
||||
|
||||
/// Every form struct protected by CSRF implements this trait so the
|
||||
/// extractor can pull the token out of the decoded body. A plain
|
||||
/// `#[serde(default)] pub csrf_token: String` plus a three-line impl
|
||||
/// of this trait is all the boilerplate each form needs.
|
||||
pub trait HasCsrfToken {
|
||||
fn csrf_token(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Wrapping extractor — a drop-in replacement for `Form<T>` on
|
||||
/// state-changing `/web/` POST handlers. Validates the form body's
|
||||
/// `csrf_token` against the session's csrf_token via a constant-time
|
||||
/// comparison.
|
||||
///
|
||||
/// Rejection mapping:
|
||||
/// - Missing or expired session → 302 redirect to `/web/login` (so a
|
||||
/// second-tab logout shows "bitte neu anmelden", not "csrf failed").
|
||||
/// - Form body malformed → 400 `BadRequest`.
|
||||
/// - Token present but mismatched → 403 `Forbidden`.
|
||||
pub struct CsrfForm<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for CsrfForm<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned + HasCsrfToken + Send + 'static,
|
||||
SessionStore: FromRef<S>,
|
||||
Arc<Config>: FromRef<S>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let store = SessionStore::from_ref(state);
|
||||
|
||||
// Look up the expected token first (headers only — the body
|
||||
// extractor below will consume `req`).
|
||||
let cookie_token = CookieJar::from_headers(req.headers())
|
||||
.get(SESSION_COOKIE)
|
||||
.map(|c| c.value().to_owned())
|
||||
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
||||
|
||||
let expected = {
|
||||
let r = store.read().await;
|
||||
let session = r
|
||||
.get(&cookie_token)
|
||||
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
||||
if session.expires_at <= Instant::now() {
|
||||
return Err(AppError::Redirect("/web/login".into()));
|
||||
}
|
||||
session.csrf_token.clone()
|
||||
};
|
||||
|
||||
// Consume the body. `axum_extra::extract::Form` supports
|
||||
// repeated fields (BulkForm's `Vec<String>`); plain
|
||||
// `axum::extract::Form` does not.
|
||||
let Form(form): Form<T> = Form::from_request(req, state)
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("form parse: {e}")))?;
|
||||
|
||||
// Constant-time compare. `ConstantTimeEq` on byte slices
|
||||
// returns `Choice(0)` when the lengths differ, so empty or
|
||||
// malformed tokens fall through here without a special case.
|
||||
let provided = form.csrf_token();
|
||||
if !bool::from(provided.as_bytes().ct_eq(expected.as_bytes())) {
|
||||
warn!(
|
||||
provided_len = provided.len(),
|
||||
expected_len = expected.len(),
|
||||
"csrf token mismatch"
|
||||
);
|
||||
return Err(AppError::Forbidden("CSRF-Token ungültig oder fehlt".into()));
|
||||
}
|
||||
|
||||
Ok(CsrfForm(form))
|
||||
}
|
||||
}
|
||||
|
||||
/// Form body with only a CSRF token — for handlers that are
|
||||
/// state-changing but carry no other user-submitted data
|
||||
/// (close/reopen/reset/analyze/logout). Using one shared type beats
|
||||
/// five wordless copy-paste structs.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CsrfOnlyForm {
|
||||
#[serde(default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
impl HasCsrfToken for CsrfOnlyForm {
|
||||
fn csrf_token(&self) -> &str {
|
||||
&self.csrf_token
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -2,6 +2,7 @@ pub mod analyze;
|
||||
pub mod auth;
|
||||
pub mod case_id;
|
||||
pub mod config;
|
||||
pub mod csrf;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod gazetteer;
|
||||
@@ -178,13 +179,23 @@ impl FromRef<AppState> for Arc<Gazetteer> {
|
||||
/// because the receivers are not retained. Use [`create_router_with_state`]
|
||||
/// from `main.rs` where real workers own the receivers.
|
||||
pub fn create_router(config: Arc<Config>) -> Router {
|
||||
create_router_and_session_store(config).0
|
||||
}
|
||||
|
||||
/// Same as [`create_router`], but also returns a handle to the newly
|
||||
/// created session store so tests can peek at `WebSession::csrf_token`
|
||||
/// values without rendering and parsing HTML. The store handle is
|
||||
/// shared with the router (`Arc`), so any session created via `/web/login`
|
||||
/// or `/web/magic` becomes observable through it.
|
||||
pub fn create_router_and_session_store(config: Arc<Config>) -> (Router, SessionStore) {
|
||||
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
|
||||
let (analyze_tx, _analyze_rx) = analyze::channel();
|
||||
create_router_with_state(AppState {
|
||||
let session_store = web_session::new_store();
|
||||
let router = create_router_with_state(AppState {
|
||||
config,
|
||||
transcribe_tx,
|
||||
analyze_tx,
|
||||
session_store: web_session::new_store(),
|
||||
session_store: session_store.clone(),
|
||||
magic_link_store: magic_link::new_store(),
|
||||
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
||||
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
||||
@@ -192,7 +203,8 @@ pub fn create_router(config: Arc<Config>) -> Router {
|
||||
events_tx: events::channel(),
|
||||
http_client: reqwest::Client::new(),
|
||||
vocab: Arc::new(Gazetteer::empty()),
|
||||
})
|
||||
});
|
||||
(router, session_store)
|
||||
}
|
||||
|
||||
pub fn create_router_with_state(state: AppState) -> Router {
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::Redirect;
|
||||
use axum_extra::extract::Form;
|
||||
use doctate_common::timestamp::now_rfc3339;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
@@ -10,6 +9,7 @@ use tracing::{info, warn};
|
||||
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
|
||||
use crate::auth::AuthenticatedWebUser;
|
||||
use crate::config::Config;
|
||||
use crate::csrf::{CsrfForm, HasCsrfToken};
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::paths::{CloseMarker, write_close_marker};
|
||||
@@ -25,6 +25,14 @@ pub struct BulkForm {
|
||||
/// (handled as a no-op).
|
||||
#[serde(default, rename = "case_id")]
|
||||
case_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
impl HasCsrfToken for BulkForm {
|
||||
fn csrf_token(&self) -> &str {
|
||||
&self.csrf_token
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to
|
||||
@@ -35,7 +43,7 @@ pub async fn handle_bulk_action(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
State(events_tx): State<EventSender>,
|
||||
Form(form): Form<BulkForm>,
|
||||
CsrfForm(form): CsrfForm<BulkForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
// Bulk actions are admin-only across the board: the UI hides the
|
||||
// selection checkboxes and action bar from non-admins, and this
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::extract::{Form, State};
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::Redirect;
|
||||
use serde::Deserialize;
|
||||
@@ -21,6 +21,7 @@ use crate::analyze::{
|
||||
use crate::auth::AuthenticatedWebUser;
|
||||
use crate::case_id::CaseIdPath;
|
||||
use crate::config::Config;
|
||||
use crate::csrf::{CsrfForm, CsrfOnlyForm, HasCsrfToken};
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::paths::{
|
||||
@@ -43,6 +44,7 @@ pub async fn handle_analyze_case(
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
if !config.llm_configured() {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
@@ -323,6 +325,7 @@ pub async fn handle_close_case(
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?;
|
||||
@@ -365,6 +368,7 @@ pub async fn handle_reopen_case(
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
let case_dir = user_root.join(case_id.to_string());
|
||||
@@ -414,6 +418,7 @@ pub async fn handle_reset_case(
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
if !user.is_admin() {
|
||||
return Err(AppError::Forbidden("Nur für Admins".into()));
|
||||
@@ -442,6 +447,14 @@ pub struct PurgeClosedForm {
|
||||
// rather than a 422 from axum's form extractor.
|
||||
#[serde(default)]
|
||||
pub confirm: String,
|
||||
#[serde(default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
impl HasCsrfToken for PurgeClosedForm {
|
||||
fn csrf_token(&self) -> &str {
|
||||
&self.csrf_token
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /web/cases/purge-closed
|
||||
@@ -458,7 +471,7 @@ pub async fn handle_purge_closed(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
Form(form): Form<PurgeClosedForm>,
|
||||
CsrfForm(form): CsrfForm<PurgeClosedForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
// Destructive, irreversible — admin-only. UI hides the button for
|
||||
// non-admins; this is the server-side defense-in-depth gate.
|
||||
@@ -517,6 +530,14 @@ pub async fn handle_purge_closed(
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordingForm {
|
||||
pub filename: String,
|
||||
#[serde(default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
impl HasCsrfToken for DeleteRecordingForm {
|
||||
fn csrf_token(&self) -> &str {
|
||||
&self.csrf_token
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /web/cases/{case_id}/recordings/delete
|
||||
@@ -538,7 +559,7 @@ pub async fn handle_delete_recording(
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<DeleteRecordingForm>,
|
||||
CsrfForm(form): CsrfForm<DeleteRecordingForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
validate_filename(&form.filename)?;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::csrf::{CsrfForm, CsrfOnlyForm};
|
||||
use crate::error::AppError;
|
||||
use crate::web_session::{
|
||||
SESSION_COOKIE, SessionStore, WebSession, build_session_cookie, generate_token,
|
||||
@@ -57,6 +58,7 @@ pub async fn handle_login_submit(
|
||||
|
||||
let user = user.unwrap();
|
||||
let token = generate_token();
|
||||
let csrf_token = generate_token();
|
||||
|
||||
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
|
||||
{
|
||||
@@ -67,6 +69,7 @@ pub async fn handle_login_submit(
|
||||
slug: user.slug.clone(),
|
||||
role: user.role.clone(),
|
||||
expires_at: Instant::now() + ttl,
|
||||
csrf_token,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -82,6 +85,7 @@ pub async fn handle_logout(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(store): State<SessionStore>,
|
||||
jar: CookieJar,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
if let Some(c) = jar.get(SESSION_COOKIE) {
|
||||
let token = c.value().to_owned();
|
||||
|
||||
@@ -119,6 +119,7 @@ pub async fn handle_consume(
|
||||
}
|
||||
|
||||
let session_token = generate_token();
|
||||
let csrf_token = generate_token();
|
||||
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
|
||||
{
|
||||
let mut w = session_store.write().await;
|
||||
@@ -128,6 +129,7 @@ pub async fn handle_consume(
|
||||
slug: pending.slug.clone(),
|
||||
role: pending.role.clone(),
|
||||
expires_at: Instant::now() + ttl,
|
||||
csrf_token,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ pub struct WebSession {
|
||||
pub slug: String,
|
||||
pub role: String,
|
||||
pub expires_at: Instant,
|
||||
/// CSRF token — bound to the session for its full lifetime. Minted
|
||||
/// alongside the session cookie via [`generate_token`] and validated
|
||||
/// by the `CsrfForm` extractor on every state-changing POST under
|
||||
/// `/web/`. Not rotated per request (would break multi-tab use).
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub type SessionStore = Arc<RwLock<HashMap<String, WebSession>>>;
|
||||
|
||||
+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()
|
||||
|
||||
@@ -121,7 +121,7 @@ async fn close_bumps_watermark() {
|
||||
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
seed_case(&data_path, "dr_a", case_id);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
|
||||
let login = app
|
||||
.clone()
|
||||
@@ -129,6 +129,13 @@ async fn close_bumps_watermark() {
|
||||
.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();
|
||||
|
||||
// First GET: capture baseline ETag (watermark empty → seeded on 0).
|
||||
let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await;
|
||||
@@ -143,7 +150,8 @@ async fn close_bumps_watermark() {
|
||||
.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(),
|
||||
)
|
||||
.await
|
||||
@@ -175,7 +183,7 @@ async fn reopen_bumps_watermark() {
|
||||
let case_id = "660e8400-e29b-41d4-a716-446655440000";
|
||||
seed_case(&data_path, "dr_a", case_id);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
|
||||
let login = app
|
||||
.clone()
|
||||
@@ -183,6 +191,13 @@ async fn reopen_bumps_watermark() {
|
||||
.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();
|
||||
|
||||
// Close first so reopen has something to restore; grab the post-close ETag.
|
||||
app.clone()
|
||||
@@ -191,7 +206,8 @@ async fn reopen_bumps_watermark() {
|
||||
.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(),
|
||||
)
|
||||
.await
|
||||
@@ -207,7 +223,8 @@ async fn reopen_bumps_watermark() {
|
||||
.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(),
|
||||
)
|
||||
.await
|
||||
@@ -236,7 +253,7 @@ async fn bulk_close_bumps_watermark() {
|
||||
seed_case(&data_path, "dr_a", case_a);
|
||||
seed_case(&data_path, "dr_a", case_b);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
|
||||
let login = app
|
||||
.clone()
|
||||
@@ -244,12 +261,19 @@ async fn bulk_close_bumps_watermark() {
|
||||
.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 (_, 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}");
|
||||
let form = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}");
|
||||
let bulk = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
//!
|
||||
//! Each test crafts a request that simulates a real attack shape
|
||||
//! (cross-site POST with valid cookie, wrong token, empty token, etc.)
|
||||
//! and asserts the expected defense. Specs marked `#[ignore]` are red
|
||||
//! today and turn green once the `CsrfForm<T>` extractor + session CSRF
|
||||
//! token land. Remove the `#[ignore]` on each as it passes.
|
||||
//!
|
||||
//! Session-hygiene tests without `#[ignore]` (session fixation rotation)
|
||||
//! document properties the server already has — they are regression
|
||||
//! anchors.
|
||||
//! and asserts the expected defense. The defense is implemented by
|
||||
//! the `CsrfForm<T>` extractor in `server/src/csrf.rs`, validated
|
||||
//! against `WebSession::csrf_token`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -141,7 +137,6 @@ async fn session_fixation_login_rotates_cookie() {
|
||||
// ---------- CSRF defense — missing token ----------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_without_csrf_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -161,7 +156,6 @@ async fn bulk_without_csrf_token_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn purge_closed_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -175,7 +169,6 @@ async fn purge_closed_without_csrf_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn reset_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -193,7 +186,6 @@ async fn reset_without_csrf_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn close_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -211,7 +203,6 @@ async fn close_without_csrf_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn reopen_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -229,7 +220,6 @@ async fn reopen_without_csrf_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn analyze_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -247,7 +237,6 @@ async fn analyze_without_csrf_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn delete_recording_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -268,7 +257,6 @@ async fn delete_recording_without_csrf_forbidden() {
|
||||
/// victim out of their active session (annoyance / phishing setup where
|
||||
/// victim re-enters password on a lookalike page).
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn logout_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -288,7 +276,6 @@ async fn logout_without_csrf_forbidden() {
|
||||
// ---------- CSRF defense — malformed token values ----------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_wrong_csrf_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -307,7 +294,6 @@ async fn bulk_with_wrong_csrf_token_forbidden() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_empty_csrf_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -329,7 +315,6 @@ async fn bulk_with_empty_csrf_token_forbidden() {
|
||||
/// an attacker could send `csrf_token=%0A%0A` and hit an early-return
|
||||
/// `if token.is_empty() { skip }` branch. Defense: compare raw.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_whitespace_padded_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
@@ -350,7 +335,6 @@ async fn bulk_with_whitespace_padded_token_forbidden() {
|
||||
/// down that the check returns a clean 403 rather than 500/panic on
|
||||
/// unusual byte content.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_injection_payload_returns_403_not_500() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
|
||||
@@ -69,13 +69,26 @@ fn login_request(slug: &str, password: &str) -> Request<Body> {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, slug: &str, password: &str) -> String {
|
||||
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();
|
||||
extract_session_cookie(&resp).expect("login should set session cookie")
|
||||
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 {
|
||||
@@ -98,8 +111,8 @@ fn seed_recording(case_dir: &Path, ts_stem: &str, transcript: &str) -> String {
|
||||
filename
|
||||
}
|
||||
|
||||
fn delete_request(case_id: &str, filename: &str, cookie: &str) -> Request<Body> {
|
||||
let body = format!("filename={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"))
|
||||
@@ -124,12 +137,12 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
|
||||
std::fs::write(case_dir.join("document.md"), b"# stale").unwrap();
|
||||
std::fs::write(case_dir.join("analysis_input.json"), b"{}").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
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))
|
||||
.oneshot(delete_request(case_id, &victim, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -184,12 +197,17 @@ async fn delete_recording_rejects_path_traversal() {
|
||||
let case_dir = seed_case_dir(&data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "2026-04-19T10-00-00Z", "safe");
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
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))
|
||||
.oneshot(delete_request(
|
||||
case_id,
|
||||
"../../../etc/passwd.m4a",
|
||||
&cookie,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -209,12 +227,12 @@ async fn delete_recording_rejects_wrong_extension() {
|
||||
let case_id = "33333333-3333-3333-3333-333333333333";
|
||||
seed_case_dir(&data_path, "dr_a", case_id);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
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))
|
||||
.oneshot(delete_request(case_id, "document.md", &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -233,12 +251,12 @@ async fn delete_recording_cross_user_returns_404() {
|
||||
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 = doctate_server::create_router(config);
|
||||
let cookie_a = login(&app, "dr_a", "s").await;
|
||||
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))
|
||||
.oneshot(delete_request(case_id, &filename, &cookie_a, &csrf_a))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -262,12 +280,12 @@ async fn delete_last_recording_clears_case() {
|
||||
let only = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "lonely");
|
||||
std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
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))
|
||||
.oneshot(delete_request(case_id, &only, &cookie, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -291,8 +309,8 @@ async fn delete_recording_accepts_failed_suffix() {
|
||||
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 = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
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()
|
||||
@@ -300,6 +318,7 @@ async fn delete_recording_accepts_failed_suffix() {
|
||||
case_id,
|
||||
"2026-04-19T10-00-00Z.m4a.failed",
|
||||
&cookie,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -222,7 +222,7 @@ 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 = doctate_server::create_router(config);
|
||||
let (app, store) = doctate_server::create_router_and_session_store(config);
|
||||
|
||||
let login = app
|
||||
.clone()
|
||||
@@ -230,6 +230,8 @@ async fn logout_clears_session() {
|
||||
.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 logout = app
|
||||
.clone()
|
||||
@@ -238,7 +240,8 @@ async fn logout_clears_session() {
|
||||
.method("POST")
|
||||
.uri("/web/logout")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!("csrf_token={csrf}")))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user