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:
2026-05-04 18:36:10 +02:00
parent 3d67cbc1c8
commit 2c6062a53e
43 changed files with 313 additions and 266 deletions
+11 -11
View File
@@ -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.