2c6062a53e
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.
178 lines
5.3 KiB
Rust
178 lines
5.3 KiB
Rust
mod common;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use tower::util::ServiceExt;
|
|
|
|
use common::{
|
|
TestConfig, body_string, extract_session_cookie, get_with_cookie, login_request, paths,
|
|
test_user_with_password,
|
|
};
|
|
|
|
#[tokio::test]
|
|
async fn login_success_sets_session_cookie_and_redirects() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "secret"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let resp = app.oneshot(login_request("dr_a", "secret")).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::SEE_OTHER.as_u16(),
|
|
"got {:?}",
|
|
resp.status()
|
|
);
|
|
|
|
let loc = resp
|
|
.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap();
|
|
assert_eq!(loc, "/cases");
|
|
|
|
let cookie = extract_session_cookie(&resp).expect("no session cookie");
|
|
assert!(cookie.starts_with("session="));
|
|
assert!(
|
|
cookie.len() > "session=".len() + 20,
|
|
"token looks too short: {cookie}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_wrong_password_shows_error() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "secret"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let resp = app.oneshot(login_request("dr_a", "wrong")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert!(extract_session_cookie(&resp).is_none());
|
|
let body = body_string(resp).await;
|
|
assert!(body.contains("Login fehlgeschlagen"), "got: {body}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_unknown_user_shows_error() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "secret"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let resp = app
|
|
.oneshot(login_request("ghost", "anything"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert!(extract_session_cookie(&resp).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cases_without_cookie_redirects_to_login() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "secret"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(paths::CASES)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
paths::LOGIN
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cases_with_valid_cookie_shows_only_own_cases() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "s"))
|
|
.with_user(test_user_with_password("dr_b", "s"))
|
|
.build();
|
|
// Seed fixtures: dr_a has an open case, dr_b has one too.
|
|
let case_a = cfg
|
|
.data_path
|
|
.join("dr_a/11111111-1111-1111-1111-111111111111");
|
|
let case_b = cfg
|
|
.data_path
|
|
.join("dr_b/22222222-2222-2222-2222-222222222222");
|
|
std::fs::create_dir_all(&case_a).unwrap();
|
|
std::fs::create_dir_all(&case_b).unwrap();
|
|
std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
|
std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(get_with_cookie(paths::CASES, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = body_string(resp).await;
|
|
assert!(body.contains("11111111"), "own case missing: {body}");
|
|
assert!(!body.contains("22222222"), "foreign case leaked: {body}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn case_detail_of_foreign_case_returns_404() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "s"))
|
|
.with_user(test_user_with_password("dr_b", "s"))
|
|
.build();
|
|
let case_b = cfg
|
|
.data_path
|
|
.join("dr_b/22222222-2222-2222-2222-222222222222");
|
|
std::fs::create_dir_all(&case_b).unwrap();
|
|
std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(get_with_cookie(
|
|
paths::case_detail("22222222-2222-2222-2222-222222222222"),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn logout_clears_session() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "s"))
|
|
.build();
|
|
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
|
|
|
let (cookie, csrf) = common::login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
let logout = app
|
|
.clone()
|
|
.oneshot(common::csrf_form_post(paths::LOGOUT, &cookie, &csrf, ""))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(logout.status(), StatusCode::SEE_OTHER.as_u16());
|
|
|
|
// Same cookie must no longer be accepted.
|
|
let resp = app
|
|
.oneshot(get_with_cookie(paths::CASES, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
|
}
|