refactor: drop /web/ URL prefix from browser routes
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.
The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.
Pre-production: no transition redirects.
This commit is contained in:
@@ -222,7 +222,7 @@ async fn analyze_case_happy_path_writes_input_and_redirects() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
format!("/web/cases/{case_id}")
|
||||
format!("/cases/{case_id}")
|
||||
);
|
||||
|
||||
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
|
||||
@@ -713,7 +713,7 @@ async fn close_writes_marker_and_hides_case() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/web/cases"
|
||||
"/cases"
|
||||
);
|
||||
let marker = case_dir.join(CLOSE_MARKER);
|
||||
assert!(marker.exists(), ".closed marker missing");
|
||||
@@ -833,12 +833,12 @@ async fn show_closed_query_includes_closed_cases() {
|
||||
.await;
|
||||
assert!(
|
||||
!body.contains(case_id),
|
||||
"default /web/cases must hide closed cases"
|
||||
"default /cases must hide closed cases"
|
||||
);
|
||||
|
||||
// With show_closed=1 it must appear, with the closed badge.
|
||||
let body = common::body_string(
|
||||
app.oneshot(get_with_cookie("/web/cases?show_closed=1", &cookie))
|
||||
app.oneshot(get_with_cookie("/cases?show_closed=1", &cookie))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -912,7 +912,7 @@ async fn close_redirects_back_to_referer_query() {
|
||||
|
||||
let mut req = csrf_form_post(paths::case_close(case_id), &cookie, &csrf, "");
|
||||
req.headers_mut()
|
||||
.insert(header::REFERER, "/web/cases?show_closed=1".parse().unwrap());
|
||||
.insert(header::REFERER, "/cases?show_closed=1".parse().unwrap());
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
@@ -922,13 +922,13 @@ async fn close_redirects_back_to_referer_query() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/web/cases?show_closed=1"
|
||||
"/cases?show_closed=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_from_detail_redirects_to_list_preserving_query() {
|
||||
// Close from /web/cases/{uuid}?show_closed=1 must NOT redirect back
|
||||
// Close from /cases/{uuid}?show_closed=1 must NOT redirect back
|
||||
// to the detail page (it 404s after the close). The uuid segment
|
||||
// is stripped; the query survives so the user lands in show-closed.
|
||||
let (cfg, settings) = cfg_llm("close-ref-det");
|
||||
@@ -954,7 +954,7 @@ async fn close_from_detail_redirects_to_list_preserving_query() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/web/cases?show_closed=1",
|
||||
"/cases?show_closed=1",
|
||||
"close from detail must redirect to the LIST with the query intact"
|
||||
);
|
||||
}
|
||||
@@ -985,7 +985,7 @@ async fn reopen_redirects_back_to_referer() {
|
||||
|
||||
let mut req = csrf_form_post(paths::case_reopen(case_id), &cookie, &csrf, "");
|
||||
req.headers_mut()
|
||||
.insert(header::REFERER, "/web/cases?show_closed=1".parse().unwrap());
|
||||
.insert(header::REFERER, "/cases?show_closed=1".parse().unwrap());
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
@@ -994,7 +994,7 @@ async fn reopen_redirects_back_to_referer() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/web/cases?show_closed=1",
|
||||
"/cases?show_closed=1",
|
||||
"reopen must redirect back to the referer path (preserving the query)"
|
||||
);
|
||||
}
|
||||
@@ -1290,7 +1290,7 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
resp.headers().get(header::LOCATION).unwrap(),
|
||||
&format!("/web/cases/{case_id}")
|
||||
&format!("/cases/{case_id}")
|
||||
);
|
||||
|
||||
// Audio preserved, .failed renamed to .m4a.
|
||||
|
||||
@@ -17,7 +17,7 @@ async fn login_dr_a(app: &axum::Router) -> String {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GET /web/cases/{id} — case page
|
||||
// GET /cases/{id} — case page
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
@@ -320,7 +320,7 @@ async fn case_page_foreign_user_returns_404() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GET /web/cases/{id}/recordings — recordings sub-page
|
||||
// GET /cases/{id}/recordings — recordings sub-page
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
@@ -373,7 +373,7 @@ async fn case_recordings_back_link_targets_case_page() {
|
||||
|
||||
assert!(
|
||||
body.contains(&format!(r#"href="{}""#, paths::case_detail(case_id))),
|
||||
"back link must target the case page, not /web/cases"
|
||||
"back link must target the case page, not /cases"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn form_post<U: AsRef<str>>(uri: U, cookie: &str, body: impl Into<String>) -
|
||||
|
||||
/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically.
|
||||
/// Pass an empty `extra_body` when the form has no other fields
|
||||
/// (e.g. POST /web/cases/{id}/close).
|
||||
/// (e.g. POST /cases/{id}/close).
|
||||
pub fn csrf_form_post<U: AsRef<str>>(
|
||||
uri: U,
|
||||
cookie: &str,
|
||||
@@ -43,7 +43,7 @@ pub fn csrf_form_post<U: AsRef<str>>(
|
||||
form_post(uri, cookie, body)
|
||||
}
|
||||
|
||||
/// GET with a session cookie — authenticated `/web/*` pages.
|
||||
/// GET with a session cookie — authenticated `/*` pages.
|
||||
pub fn get_with_cookie<U: AsRef<str>>(uri: U, cookie: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
|
||||
@@ -9,65 +9,65 @@
|
||||
|
||||
pub use doctate_common::{ONELINERS_PATH, UPLOAD_PATH};
|
||||
|
||||
// -- /web/* --
|
||||
// -- browser UI routes --
|
||||
|
||||
/// `POST /web/login` — form login (slug, password).
|
||||
pub const LOGIN: &str = "/web/login";
|
||||
/// `POST /web/logout` — CSRF-protected logout.
|
||||
pub const LOGOUT: &str = "/web/logout";
|
||||
/// `GET /web/cases` — case list; also triggers the lazy retention sweep.
|
||||
pub const CASES: &str = "/web/cases";
|
||||
/// `POST /web/cases/bulk` — admin bulk action (close/analyze/reset).
|
||||
pub const BULK: &str = "/web/cases/bulk";
|
||||
/// `POST /web/cases/purge-closed` — admin hard-delete of closed cases.
|
||||
pub const PURGE_CLOSED: &str = "/web/cases/purge-closed";
|
||||
/// `GET /web/events` — SSE stream (per-user scoped).
|
||||
pub const EVENTS: &str = "/web/events";
|
||||
/// `POST /login` — form login (slug, password).
|
||||
pub const LOGIN: &str = "/login";
|
||||
/// `POST /logout` — CSRF-protected logout.
|
||||
pub const LOGOUT: &str = "/logout";
|
||||
/// `GET /cases` — case list; also triggers the lazy retention sweep.
|
||||
pub const CASES: &str = "/cases";
|
||||
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
|
||||
pub const BULK: &str = "/cases/bulk";
|
||||
/// `POST /cases/purge-closed` — admin hard-delete of closed cases.
|
||||
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
|
||||
/// `GET /events` — SSE stream (per-user scoped).
|
||||
pub const EVENTS: &str = "/events";
|
||||
|
||||
// -- per-case builders --
|
||||
|
||||
/// `/web/cases/{case_id}`.
|
||||
/// `/cases/{case_id}`.
|
||||
pub fn case_detail(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}")
|
||||
format!("/cases/{case_id}")
|
||||
}
|
||||
|
||||
/// `/web/cases/{case_id}/recordings`.
|
||||
/// `/cases/{case_id}/recordings`.
|
||||
pub fn case_recordings(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/recordings")
|
||||
format!("/cases/{case_id}/recordings")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/close`.
|
||||
/// `POST /cases/{case_id}/close`.
|
||||
pub fn case_close(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/close")
|
||||
format!("/cases/{case_id}/close")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/reopen`.
|
||||
/// `POST /cases/{case_id}/reopen`.
|
||||
pub fn case_reopen(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/reopen")
|
||||
format!("/cases/{case_id}/reopen")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/analyze`.
|
||||
/// `POST /cases/{case_id}/analyze`.
|
||||
pub fn case_analyze(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/analyze")
|
||||
format!("/cases/{case_id}/analyze")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/reset` — admin-only hard-reset.
|
||||
/// `POST /cases/{case_id}/reset` — admin-only hard-reset.
|
||||
pub fn case_reset(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/reset")
|
||||
format!("/cases/{case_id}/reset")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/recordings/delete` — per-recording delete.
|
||||
/// `POST /cases/{case_id}/recordings/delete` — per-recording delete.
|
||||
pub fn recording_delete(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/recordings/delete")
|
||||
format!("/cases/{case_id}/recordings/delete")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/oneliner` — session-authenticated manual
|
||||
/// `POST /cases/{case_id}/oneliner` — session-authenticated manual
|
||||
/// oneliner override (browser web UI).
|
||||
pub fn case_oneliner_web(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/oneliner")
|
||||
format!("/cases/{case_id}/oneliner")
|
||||
}
|
||||
|
||||
/// `GET /web/magic?token={token}` — magic-link consumption.
|
||||
/// `GET /magic?token={token}` — magic-link consumption.
|
||||
pub fn magic(token: &str) -> String {
|
||||
format!("/web/magic?token={token}")
|
||||
format!("/magic?token={token}")
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn extract_session_cookie(resp: &Response) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the `POST /web/login` request with a URL-encoded form body.
|
||||
/// Build the `POST /login` request with a URL-encoded form body.
|
||||
/// Pulled out so tests that need to *inspect* the login response
|
||||
/// (e.g. session-fixation tests) can reuse the exact wire shape.
|
||||
pub fn login_request(slug: &str, password: &str) -> Request<Body> {
|
||||
@@ -62,7 +62,7 @@ pub async fn login(app: &Router, slug: &str, password: &str) -> String {
|
||||
/// the session store. Pairs with
|
||||
/// `doctate_server::create_router_and_session_store` — the returned
|
||||
/// token is what the `CsrfForm<T>` extractor validates on every
|
||||
/// state-changing POST under `/web/`.
|
||||
/// state-changing POST under `/`.
|
||||
///
|
||||
/// Returns `(cookie_header_value, csrf_token)`.
|
||||
pub async fn login_with_csrf(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Attack-confirming tests for CSRF protection on /web/ state-changing
|
||||
//! Attack-confirming tests for CSRF protection on / state-changing
|
||||
//! endpoints.
|
||||
//!
|
||||
//! Each test crafts a request that simulates a real attack shape
|
||||
@@ -185,7 +185,7 @@ async fn delete_recording_without_csrf_forbidden() {
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the
|
||||
/// Forced-logout CSRF: attacker page auto-POSTs /logout to sign the
|
||||
/// victim out of their active session (annoyance / phishing setup where
|
||||
/// victim re-enters password on a lookalike page).
|
||||
#[tokio::test]
|
||||
@@ -334,18 +334,17 @@ async fn my_cases_page_renders_csrf_token_hidden_field() {
|
||||
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
||||
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_admin", "s").await;
|
||||
|
||||
let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await;
|
||||
let body = html_body(&app, "/cases?show_closed=1", &cookie).await;
|
||||
let expected = hidden_field(&csrf);
|
||||
|
||||
assert!(
|
||||
body.contains(&expected),
|
||||
"/web/cases should render hidden csrf_token ({expected}), \
|
||||
"/cases should render hidden csrf_token ({expected}), \
|
||||
first 200 chars of body: {}",
|
||||
&body[..body.len().min(200)]
|
||||
);
|
||||
assert!(
|
||||
body.contains(r#"action="/web/logout""#)
|
||||
&& body.matches(r#"name="csrf_token""#).count() >= 2,
|
||||
body.contains(r#"action="/logout""#) && body.matches(r#"name="csrf_token""#).count() >= 2,
|
||||
"expected at least 2 csrf_token hidden fields (logout + bulk); \
|
||||
got {} occurrences",
|
||||
body.matches(r#"name="csrf_token""#).count()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Per-recording delete endpoint: POST /web/cases/{case_id}/recordings/delete.
|
||||
//! Per-recording delete endpoint: POST /cases/{case_id}/recordings/delete.
|
||||
//!
|
||||
//! Verifies the endpoint:
|
||||
//! - removes the audio file and its `<stem>.json` metadata sidecar
|
||||
@@ -59,7 +59,7 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
|
||||
"delete should redirect (3xx), got {}",
|
||||
resp.status()
|
||||
);
|
||||
let expected_location = format!("/web/cases/{case_id}/recordings");
|
||||
let expected_location = format!("/cases/{case_id}/recordings");
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(axum::http::header::LOCATION)
|
||||
@@ -217,7 +217,7 @@ async fn delete_last_recording_clears_case() {
|
||||
"last-recording delete should still redirect, got {}",
|
||||
resp.status()
|
||||
);
|
||||
let expected_location = format!("/web/cases/{case_id}/recordings");
|
||||
let expected_location = format!("/cases/{case_id}/recordings");
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(axum::http::header::LOCATION)
|
||||
|
||||
@@ -30,7 +30,7 @@ async fn login_success_sets_session_cookie_and_redirects() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(loc, "/web/cases");
|
||||
assert_eq!(loc, "/cases");
|
||||
|
||||
let cookie = extract_session_cookie(&resp).expect("no session cookie");
|
||||
assert!(cookie.starts_with("session="));
|
||||
|
||||
@@ -86,7 +86,7 @@ async fn create_with_valid_key_returns_token() {
|
||||
let app = test_app();
|
||||
let resp = app
|
||||
.oneshot(create_request(
|
||||
r#"{"return_to":"/web/cases/abc"}"#,
|
||||
r#"{"return_to":"/cases/abc"}"#,
|
||||
Some(API_KEY),
|
||||
))
|
||||
.await
|
||||
@@ -106,7 +106,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() {
|
||||
let create_resp = app
|
||||
.clone()
|
||||
.oneshot(create_request(
|
||||
r#"{"return_to":"/web/cases/abc"}"#,
|
||||
r#"{"return_to":"/cases/abc"}"#,
|
||||
Some(API_KEY),
|
||||
))
|
||||
.await
|
||||
@@ -125,7 +125,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() {
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/web/cases/abc"
|
||||
"/cases/abc"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
@@ -167,16 +167,13 @@ async fn consume_token_is_one_time_use() {
|
||||
assert_eq!(first.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
first.headers().get(header::LOCATION).unwrap(),
|
||||
"/web/cases" // default
|
||||
"/cases" // default
|
||||
);
|
||||
|
||||
let second = app.oneshot(consume_request(&token)).await.unwrap();
|
||||
// Second use: token gone, redirect to login.
|
||||
assert_eq!(second.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
second.headers().get(header::LOCATION).unwrap(),
|
||||
"/web/login"
|
||||
);
|
||||
assert_eq!(second.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -217,7 +214,7 @@ async fn consume_expired_token_redirects_to_login() {
|
||||
PendingMagicLink {
|
||||
slug: "dr_a".into(),
|
||||
role: "doctor".into(),
|
||||
return_to: "/web/cases".into(),
|
||||
return_to: "/cases".into(),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
@@ -226,7 +223,7 @@ async fn consume_expired_token_redirects_to_login() {
|
||||
let app = doctate_server::create_router_with_state(state);
|
||||
let resp = app.oneshot(consume_request(&token)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/login");
|
||||
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
|
||||
// And the token must be removed from the store, even though it expired.
|
||||
assert!(store.read().await.get(&token).is_none());
|
||||
|
||||
@@ -423,7 +423,7 @@ async fn parallel_puts_serialize_via_mutex() {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 9-15. Web-UI endpoint: POST /web/cases/{case_id}/oneliner
|
||||
// 9-15. Web-UI endpoint: POST /cases/{case_id}/oneliner
|
||||
//
|
||||
// Same disk semantics as the API PUT (both call `apply_manual_override`),
|
||||
// but with browser auth: session cookie + CSRF form field. The closed-
|
||||
@@ -515,7 +515,7 @@ async fn web_put_oneliner_without_session_redirects_to_login() {
|
||||
.await
|
||||
.unwrap();
|
||||
// The CsrfForm extractor maps a missing session to a 302 redirect
|
||||
// to /web/login (`AppError::Redirect`). Browsers follow it as a
|
||||
// to /login (`AppError::Redirect`). Browsers follow it as a
|
||||
// GET, which is exactly what we want — the user lands on the login
|
||||
// page instead of staring at a 401.
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
@@ -524,7 +524,7 @@ async fn web_put_oneliner_without_session_redirects_to_login() {
|
||||
.get(header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(loc, "/web/login");
|
||||
assert_eq!(loc, "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Admin-only case reset endpoint: POST /web/cases/{case_id}/reset.
|
||||
//! Admin-only case reset endpoint: POST /cases/{case_id}/reset.
|
||||
//!
|
||||
//! Verifies the sticky-Manual rule for the reset path: a clinician-set
|
||||
//! `OnelinerState::Manual` must survive an admin-triggered reset, while
|
||||
@@ -58,7 +58,7 @@ async fn reset_case_preserves_manual_oneliner_override() {
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
resp.headers().get(header::LOCATION).unwrap(),
|
||||
&format!("/web/cases/{case_id}")
|
||||
&format!("/cases/{case_id}")
|
||||
);
|
||||
|
||||
// Recording metadata sidecar was wiped (the rest of the reset
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Lazy retention sweep: GET /web/cases must auto-close stale cases
|
||||
//! Lazy retention sweep: GET /cases must auto-close stale cases
|
||||
//! and auto-purge old closed cases, driven by per-user retention
|
||||
//! settings from users.toml. Tests age the *comparison objects*
|
||||
//! (m4a mtime, closed_at string) instead of faking "now" — gives
|
||||
@@ -16,7 +16,7 @@ use common::{
|
||||
seed_recording_with_age, test_user_with_retention, write_closed_marker,
|
||||
};
|
||||
|
||||
/// Trigger the sweep via its natural path: GET /web/cases.
|
||||
/// Trigger the sweep via its natural path: GET /cases.
|
||||
async fn trigger_sweep(app: &axum::Router, cookie: &str) {
|
||||
let resp = app
|
||||
.clone()
|
||||
|
||||
@@ -52,7 +52,7 @@ async fn api_health_has_all_security_headers() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_login_page_has_all_security_headers() {
|
||||
let resp = get(test_app(), "/web/login").await;
|
||||
let resp = get(test_app(), "/login").await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_opt(&resp, "content-security-policy").is_some());
|
||||
@@ -60,7 +60,7 @@ async fn web_login_page_has_all_security_headers() {
|
||||
|
||||
// ---------- per-attack tests ----------
|
||||
|
||||
/// Clickjacking: attacker embeds /web/cases in a hidden iframe on their
|
||||
/// Clickjacking: attacker embeds /cases in a hidden iframe on their
|
||||
/// own page and tricks the victim into clicking overlaid elements.
|
||||
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
|
||||
#[tokio::test]
|
||||
@@ -147,9 +147,9 @@ async fn permissions_policy_blocks_sensitive_features() {
|
||||
/// short-circuit on error paths.
|
||||
#[tokio::test]
|
||||
async fn error_redirect_still_carries_security_headers() {
|
||||
// /web/cases without cookie → 302 redirect (error path from the
|
||||
// /cases without cookie → 302 redirect (error path from the
|
||||
// session extractor).
|
||||
let resp = get(test_app(), "/web/cases").await;
|
||||
let resp = get(test_app(), "/cases").await;
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
@@ -162,7 +162,7 @@ async fn error_redirect_still_carries_security_headers() {
|
||||
/// yet) and must keep passing after the layer lands.
|
||||
#[tokio::test]
|
||||
async fn magic_route_referrer_policy_not_duplicated() {
|
||||
let resp = get(test_app(), "/web/magic?token=definitely-invalid").await;
|
||||
let resp = get(test_app(), "/magic?token=definitely-invalid").await;
|
||||
assert_eq!(
|
||||
count_header_values(&resp, "referrer-policy"),
|
||||
1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Regression: SSE connection-pool leak via missing `EventSource.close()`.
|
||||
//!
|
||||
//! Every HTML template that opens `new EventSource('/web/events')` must
|
||||
//! Every HTML template that opens `new EventSource('/events')` must
|
||||
//! also close it on `pagehide`. Without the close, Chrome keeps the
|
||||
//! socket in a "draining" state after navigation; after ~6 rapid
|
||||
//! back-and-forth navigations all 6 per-origin pool slots are draining
|
||||
@@ -24,7 +24,7 @@ fn every_event_source_template_closes_it_on_pagehide() {
|
||||
continue;
|
||||
}
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
if !content.contains("new EventSource('/web/events')") {
|
||||
if !content.contains("new EventSource('/events')") {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
@@ -37,7 +37,7 @@ fn every_event_source_template_closes_it_on_pagehide() {
|
||||
|
||||
assert!(
|
||||
checked >= 1,
|
||||
"test itself is stale: no template contains EventSource('/web/events')",
|
||||
"test itself is stale: no template contains EventSource('/events')",
|
||||
);
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Integration tests for the `/web/events` SSE route.
|
||||
//! Integration tests for the `/events` SSE route.
|
||||
//!
|
||||
//! We test two things end-to-end:
|
||||
//! 1. Without a session cookie, the route redirects to the login page
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn upload_reopens_closed_case() {
|
||||
|
||||
// Close the case by writing the marker directly — simulates the
|
||||
// user hitting the close button in the web UI without having to
|
||||
// drive the /web/cases/{id}/delete handler here. Direct write also
|
||||
// drive the /cases/{id}/delete handler here. Direct write also
|
||||
// means the watermark is NOT bumped here; that way the next GET
|
||||
// /api/oneliners pins down the pre-reopen ETag cleanly.
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
|
||||
@@ -118,7 +118,7 @@ async fn login_with_uppercase_slug_returns_login_failed_page() {
|
||||
}
|
||||
|
||||
/// A malformed magic-link token must not 400 (would leak shape) — it
|
||||
/// should redirect to /web/login, identical to the unknown-token path.
|
||||
/// should redirect to /login, identical to the unknown-token path.
|
||||
#[tokio::test]
|
||||
async fn magic_consume_with_malformed_token_redirects_to_login() {
|
||||
let cfg = TestConfig::new()
|
||||
@@ -131,7 +131,7 @@ async fn magic_consume_with_malformed_token_redirects_to_login() {
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/magic?token=abc")
|
||||
.uri("/magic?token=abc")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -144,7 +144,7 @@ async fn magic_consume_with_malformed_token_redirects_to_login() {
|
||||
.expect("redirect must set Location")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(loc, "/web/login");
|
||||
assert_eq!(loc, "/login");
|
||||
}
|
||||
|
||||
/// And the same for tokens that pass the length check but contain
|
||||
@@ -158,7 +158,7 @@ async fn magic_consume_with_non_urlsafe_token_redirects_to_login() {
|
||||
let app = doctate_server::create_router(cfg);
|
||||
|
||||
let bad = "%2E%2E%2Fetc%2Fpasswd%2E%2E%2Fetc"; // url-encoded ./../etc/passwd…
|
||||
let uri = format!("/web/magic?token={bad}");
|
||||
let uri = format!("/magic?token={bad}");
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
|
||||
@@ -29,7 +29,7 @@ async fn web_audio_serves_file() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
|
||||
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -50,7 +50,7 @@ async fn web_audio_path_traversal_rejected() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
|
||||
.uri("/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -67,7 +67,7 @@ async fn web_audio_invalid_case_id_rejected() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/audio/dr_test/not-a-uuid/foo.m4a")
|
||||
.uri("/audio/dr_test/not-a-uuid/foo.m4a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -93,7 +93,7 @@ async fn web_audio_serves_range_as_206() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
|
||||
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
|
||||
.header("Range", "bytes=10-19")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
@@ -129,7 +129,7 @@ async fn web_audio_invalid_range_returns_416() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
|
||||
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
|
||||
.header("Range", "bytes=1000-2000")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
@@ -160,7 +160,7 @@ async fn web_audio_no_range_header_advertises_accept_ranges() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
|
||||
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -182,7 +182,7 @@ async fn web_audio_nonexistent_file_returns_404() {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
|
||||
.uri("/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user