diff --git a/clients/desktop/src/app.rs b/clients/desktop/src/app.rs index 8763beb..fb33b8c 100644 --- a/clients/desktop/src/app.rs +++ b/clients/desktop/src/app.rs @@ -340,7 +340,7 @@ impl DoctateApp { let server_url = cfg.server_url.clone(); let api_key = cfg.api_key.clone(); let http = self.http_client.clone(); - let return_to = format!("/web/cases/{case_id}"); + let return_to = format!("/cases/{case_id}"); let fallback_url = join_url(&server_url, &return_to); // Fire-and-forget on the runtime. Blocking the egui UI thread on diff --git a/clients/desktop/src/magic_link.rs b/clients/desktop/src/magic_link.rs index 60849c6..9254c74 100644 --- a/clients/desktop/src/magic_link.rs +++ b/clients/desktop/src/magic_link.rs @@ -35,7 +35,7 @@ pub enum MagicLinkError { /// Request a magic-link token and build the full URL to open. The /// `return_to` path is what the browser lands on **after** the token -/// is consumed; must start with `/web/` (server enforces this too). +/// is consumed; must start with `/` (server enforces this too). pub async fn build_magic_url( http: &reqwest::Client, server_url: &str, @@ -56,7 +56,7 @@ pub async fn build_magic_url( let parsed: CreateResponse = resp.json().await?; Ok(join_url( server_url, - &format!("/web/magic?token={}", parsed.token), + &format!("/magic?token={}", parsed.token), )) } @@ -80,10 +80,10 @@ mod tests { .await; let http = reqwest::Client::new(); - let url = build_magic_url(&http, &server.uri(), "test-key", "/web/cases/abc") + let url = build_magic_url(&http, &server.uri(), "test-key", "/cases/abc") .await .expect("magic url"); - assert_eq!(url, format!("{}/web/magic?token={token}", server.uri())); + assert_eq!(url, format!("{}/magic?token={token}", server.uri())); } #[tokio::test] @@ -96,7 +96,7 @@ mod tests { .await; let http = reqwest::Client::new(); - let err = build_magic_url(&http, &server.uri(), "wrong-key", "/web/cases/abc") + let err = build_magic_url(&http, &server.uri(), "wrong-key", "/cases/abc") .await .expect_err("should error"); assert!(matches!(err, MagicLinkError::Status(s) if s.as_u16() == 401)); @@ -113,9 +113,9 @@ mod tests { let http = reqwest::Client::new(); let url_with_slash = format!("{}/", server.uri()); - let url = build_magic_url(&http, &url_with_slash, "k", "/web/cases/abc") + let url = build_magic_url(&http, &url_with_slash, "k", "/cases/abc") .await .expect("magic url"); - assert!(!url.contains("//web/"), "double slash leaked: {url}"); + assert!(!url.contains("//magic"), "double slash leaked: {url}"); } } diff --git a/common/src/bulk.rs b/common/src/bulk.rs index 38105c5..82e2eb6 100644 --- a/common/src/bulk.rs +++ b/common/src/bulk.rs @@ -1,5 +1,5 @@ //! Shared vocabulary for the admin bulk-action endpoint (`POST -//! /web/cases/bulk`). +//! /cases/bulk`). //! //! The wire shape is `application/x-www-form-urlencoded` with an //! `action=` field and one or more `case_id=` repetitions. diff --git a/scripts/dictate.sh b/scripts/dictate.sh index 862b410..0ec3ccb 100755 --- a/scripts/dictate.sh +++ b/scripts/dictate.sh @@ -83,7 +83,7 @@ show_case() { fi echo - echo "Browse: $SERVER_URL/web/cases" + echo "Browse: $SERVER_URL/cases" } # Record + upload for the given case, then display the case state. diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index abd7ab2..91192ac 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -1,6 +1,6 @@ //! Opportunistic auto-trigger for LLM analysis. //! -//! Called by the `/web/cases` and `/web/cases/{id}` handlers on every +//! Called by the `/cases` and `/cases/{id}` handlers on every //! request. The SSE-driven `location.reload()` in the UI turns every //! relevant state change (upload, transcript, document) into a fresh //! handler invocation, so no background timer is needed. @@ -154,7 +154,7 @@ pub async fn try_enqueue(case_dir: &Path, tx: &AnalyzeSender, events_tx: &EventS /// Iterate over every non-deleted UUID-named case directory under /// `user_root` and run `try_enqueue` on each. Safe to call on every -/// `/web/cases` render — skips are cheap filesystem stats. +/// `/cases` render — skips are cheap filesystem stats. pub async fn try_enqueue_all_for_user( user_root: &Path, tx: &AnalyzeSender, diff --git a/server/src/auth.rs b/server/src/auth.rs index 47b9cc5..a6bfd7e 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -70,7 +70,7 @@ where } /// Extracted from the session cookie on web UI requests. -/// On missing/expired session, returns `AppError::Redirect("/web/login")`. +/// On missing/expired session, returns `AppError::Redirect("/login")`. pub struct AuthenticatedWebUser { pub slug: String, pub role: String, @@ -112,7 +112,7 @@ where let token = jar .get(SESSION_COOKIE) .map(|c| c.value().to_owned()) - .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + .ok_or_else(|| AppError::Redirect("/login".into()))?; // Read lock first to check validity. If expired, upgrade to write and remove. let expired = { @@ -121,7 +121,7 @@ where Some(s) => s.expires_at <= Instant::now(), None => { warn!(token_prefix = %token_prefix(&token), "session lookup failed (unknown or already expired)"); - return Err(AppError::Redirect("/web/login".into())); + return Err(AppError::Redirect("/login".into())); } } }; @@ -132,13 +132,13 @@ where if let Some(s) = removed { warn!(slug = %s.slug, "session expired"); } - return Err(AppError::Redirect("/web/login".into())); + return Err(AppError::Redirect("/login".into())); } let r = store.read().await; let session = r .get(&token) - .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + .ok_or_else(|| AppError::Redirect("/login".into()))?; let data_dir = config.data_path.join(&session.slug); // Look up the TOML user record for the policy fields the session diff --git a/server/src/csrf.rs b/server/src/csrf.rs index f40472e..16daee6 100644 --- a/server/src/csrf.rs +++ b/server/src/csrf.rs @@ -1,4 +1,4 @@ -//! CSRF-token validation for state-changing POSTs under `/web/`. +//! CSRF-token validation for state-changing browser POSTs. //! //! The `WebSession` carries a `csrf_token` minted once at login or //! magic-link consume. Browser forms include a hidden `csrf_token` @@ -32,12 +32,12 @@ pub trait HasCsrfToken { } /// Wrapping extractor — a drop-in replacement for `Form` on -/// state-changing `/web/` POST handlers. Validates the form body's +/// state-changing browser 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 +/// - Missing or expired session → 302 redirect to `/login` (so a /// second-tab logout shows "bitte neu anmelden", not "csrf failed"). /// - Form body malformed → 400 `BadRequest`. /// - Token present but mismatched → 403 `Forbidden`. @@ -60,15 +60,15 @@ where 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()))?; + .ok_or_else(|| AppError::Redirect("/login".into()))?; let expected = { let r = store.read().await; let session = r .get(&cookie_token) - .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + .ok_or_else(|| AppError::Redirect("/login".into()))?; if session.expires_at <= Instant::now() { - return Err(AppError::Redirect("/web/login".into())); + return Err(AppError::Redirect("/login".into())); } session.csrf_token.clone() }; @@ -104,8 +104,8 @@ where /// /// `return_to` is optional and only consulted by handlers that route /// the user back to the source page (analyze, reset). Other handlers -/// ignore it. Validation (must start with `/web/`) lives at the call -/// site. +/// ignore it. Validation (same-origin, not under `/api/`) lives at the +/// call site. #[derive(serde::Deserialize)] pub struct CsrfOnlyForm { #[serde(default)] diff --git a/server/src/events.rs b/server/src/events.rs index 5282771..31a18a3 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -1,6 +1,6 @@ //! Broadcast bus for UI live-update events. //! -//! The web UI subscribes via the `/web/events` SSE route (see +//! The web UI subscribes via the `/events` SSE route (see //! `routes::events`). Background workers and request handlers call //! [`emit`] whenever they mutate case data on disk; subscribed browsers //! then schedule a debounced reload. The filesystem remains the source diff --git a/server/src/lib.rs b/server/src/lib.rs index 26a1915..f5e4224 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -207,8 +207,8 @@ pub fn create_router(config: Arc) -> Router { /// 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. +/// shared with the router (`Arc`), so any session created via `/login` +/// or `/magic` becomes observable through it. pub fn create_router_and_session_store(config: Arc) -> (Router, SessionStore) { create_router_and_session_store_with_settings(config, Arc::new(Settings::default())) } diff --git a/server/src/magic_link.rs b/server/src/magic_link.rs index 192dbdf..08fcbc2 100644 --- a/server/src/magic_link.rs +++ b/server/src/magic_link.rs @@ -2,7 +2,7 @@ //! browser session without an interactive password login. //! //! Lifecycle: the desktop client calls `POST /api/auth/magic-link` (API-key -//! authenticated), gets a token, opens the browser at `/web/magic?token=…`. +//! authenticated), gets a token, opens the browser at `/magic?token=…`. //! The web handler removes the token (one-time-use) and installs a regular //! `WebSession`. TTL is intentionally short — the token only needs to //! survive the click → browser-launch → first-request round trip. diff --git a/server/src/main.rs b/server/src/main.rs index 4ceba00..2cdbaa0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -131,7 +131,7 @@ async fn main() { }); // Live-update bus: background workers and handlers publish case - // events here; the SSE route at `/web/events` fans them out to + // events here; the SSE route at `/events` fans them out to // subscribed browsers. let events_tx = events::channel(); diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index 9e0a3d6..04c5004 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -39,7 +39,7 @@ impl HasCsrfToken for BulkForm { } } -/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to +/// POST /cases/bulk — apply `action` (analyze | close | reset) to /// every selected case. Errors per case are logged but do not abort the /// batch: a single bad/missing case must not block the rest. pub async fn handle_bulk_action( @@ -85,7 +85,7 @@ pub async fn handle_bulk_action( } } - Ok(Redirect::to("/web/cases")) + Ok(Redirect::to("/cases")) } async fn bulk_analyze( diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index b743b72..c97302e 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -33,7 +33,7 @@ use crate::paths::{ use crate::routes::user_web::locate_case_or_404; use crate::routes::web::validate_filename; -/// Form body for `POST /web/cases/{id}/analyze`. The `backend` value is set +/// Form body for `POST /cases/{id}/analyze`. The `backend` value is set /// by the clicked submit button (` @@ -362,7 +362,7 @@
{% call csrf::field(csrf_token) %}
{% if is_admin %} -
+ {% call csrf::field(csrf_token) %} - + {% for b in backends %} @@ -456,10 +456,10 @@ {% call csrf::field(csrf_token) %} - +
@@ -639,13 +639,13 @@
{% call csrf::field(csrf_token) %} {% for b in backends %}
+
{% call csrf::field(csrf_token) %}

Aufnahmen

@@ -143,12 +143,12 @@ try {
-
+
0:00 / 0:00
-
+ {% call csrf::field(csrf_token) %} @@ -406,7 +406,7 @@ try { else location.reload(); }, 300); }; - const es = new EventSource('/web/events'); + const es = new EventSource('/events'); // Release the socket-pool slot on navigation. Without this, Chrome // keeps the SSE connection "draining" after unload; six rapid nav // cycles exhaust the 6-per-origin pool and stall further requests. diff --git a/server/templates/login.html b/server/templates/login.html index d639084..1351ab9 100644 --- a/server/templates/login.html +++ b/server/templates/login.html @@ -20,7 +20,7 @@ button { cursor: pointer; margin-top: 0.4em; }
{{ msg }}
{% when None %} {% endmatch %} - + diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index daeef43..f39baa2 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -88,22 +88,22 @@ try {

{{ display_name }}

{% if is_admin %}{% endif %} -{% call csrf::field(csrf_token) %} +
{% call csrf::field(csrf_token) %}
{% if show_closed %} -← Nur offene Fälle anzeigen +← Nur offene Fälle anzeigen {% else %} -Geschlossene Fälle einblenden +Geschlossene Fälle einblenden {% endif %} {% if total == 0 %}

{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}

{% else %} -
+ {% call csrf::field(csrf_token) %} - + {% for g in groups %}
@@ -114,19 +114,19 @@ try { {% if is_admin %}
{% endif %}
-{% if is_admin %} -{% endif %} +{% if is_admin %} +{% endif %} {% if case.is_closed %} - + {% else %} - + {% endif %}
@@ -143,7 +143,7 @@ try {
{% endif %} {% if is_admin && show_closed && any_closed %} -
+ {% call csrf::field(csrf_token) %} Geschlossene Fälle: @@ -222,7 +222,7 @@ try { clearTimeout(timer); timer = setTimeout(() => location.reload(), 300); }; - const es = new EventSource('/web/events'); + const es = new EventSource('/events'); // Release the socket-pool slot on navigation. Without this, Chrome // keeps the SSE connection "draining" after unload; six rapid nav // cycles exhaust the 6-per-origin pool and stall further requests. diff --git a/server/templates/partials/oneliner_edit.js b/server/templates/partials/oneliner_edit.js index 13c94b3..e4364b2 100644 --- a/server/templates/partials/oneliner_edit.js +++ b/server/templates/partials/oneliner_edit.js @@ -1,5 +1,5 @@ // Inline tap-to-edit for .oneliner-edit blocks. Click swaps the inner -// .oneliner-text for an ; Enter POSTs to /web/cases/{id}/oneliner; +// .oneliner-text for an ; Enter POSTs to /cases/{id}/oneliner; // Escape (or blur) cancels. The SSE-driven reload picks up the persisted // state, so no DOM patching is needed on success. (() => { @@ -37,7 +37,7 @@ }); try { const r = await fetch( - `/web/cases/${host.dataset.caseId}/oneliner`, + `/cases/${host.dataset.caseId}/oneliner`, { method: 'POST', body, credentials: 'same-origin' } ); if (!r.ok) { diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index e33d2de..dd82720 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -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. diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 2860141..a72b7a7 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -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" ); } diff --git a/server/tests/common/http.rs b/server/tests/common/http.rs index a90f285..6bb6e04 100644 --- a/server/tests/common/http.rs +++ b/server/tests/common/http.rs @@ -28,7 +28,7 @@ pub fn form_post>(uri: U, cookie: &str, body: impl Into) - /// 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>( uri: U, cookie: &str, @@ -43,7 +43,7 @@ pub fn csrf_form_post>( 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>(uri: U, cookie: &str) -> Request { Request::builder() .method("GET") diff --git a/server/tests/common/paths.rs b/server/tests/common/paths.rs index 64f0b44..f3467ab 100644 --- a/server/tests/common/paths.rs +++ b/server/tests/common/paths.rs @@ -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}") } diff --git a/server/tests/common/session.rs b/server/tests/common/session.rs index ebfecdb..39cd791 100644 --- a/server/tests/common/session.rs +++ b/server/tests/common/session.rs @@ -30,7 +30,7 @@ pub fn extract_session_cookie(resp: &Response) -> Option { 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 { @@ -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` 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( diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs index 76694d3..ba21711 100644 --- a/server/tests/csrf_attack_test.rs +++ b/server/tests/csrf_attack_test.rs @@ -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() diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index 7519d50..94fd9fd 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -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 `.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) diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index b64dafe..1fa3167 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -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=")); diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs index beaf5e5..81ba93f 100644 --- a/server/tests/magic_link_test.rs +++ b/server/tests/magic_link_test.rs @@ -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()); diff --git a/server/tests/oneliner_override_test.rs b/server/tests/oneliner_override_test.rs index 54165ae..05c8c20 100644 --- a/server/tests/oneliner_override_test.rs +++ b/server/tests/oneliner_override_test.rs @@ -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] diff --git a/server/tests/reset_case_test.rs b/server/tests/reset_case_test.rs index 7203820..0bc197c 100644 --- a/server/tests/reset_case_test.rs +++ b/server/tests/reset_case_test.rs @@ -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 diff --git a/server/tests/retention_sweep_test.rs b/server/tests/retention_sweep_test.rs index 99e8340..0ea19f7 100644 --- a/server/tests/retention_sweep_test.rs +++ b/server/tests/retention_sweep_test.rs @@ -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() diff --git a/server/tests/security_headers_test.rs b/server/tests/security_headers_test.rs index d0ebce1..230b50c 100644 --- a/server/tests/security_headers_test.rs +++ b/server/tests/security_headers_test.rs @@ -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, diff --git a/server/tests/sse_cleanup_test.rs b/server/tests/sse_cleanup_test.rs index c02c7f3..c1e75c2 100644 --- a/server/tests/sse_cleanup_test.rs +++ b/server/tests/sse_cleanup_test.rs @@ -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(), diff --git a/server/tests/sse_integration.rs b/server/tests/sse_integration.rs index 72a8868..606f76d 100644 --- a/server/tests/sse_integration.rs +++ b/server/tests/sse_integration.rs @@ -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 diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index c20ba23..509c2e7 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -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); diff --git a/server/tests/validate_boundary_test.rs b/server/tests/validate_boundary_test.rs index 2eaf1df..0d5c90d 100644 --- a/server/tests/validate_boundary_test.rs +++ b/server/tests/validate_boundary_test.rs @@ -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 diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 8492dbe..6426bdd 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -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(), )