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.
168 lines
5.8 KiB
Rust
168 lines
5.8 KiB
Rust
//! End-to-end checks that boundary validators in `crate::validate` are
|
|
//! actually wired into their respective handlers and produce the right
|
|
//! response shape per endpoint.
|
|
//!
|
|
//! Per-validator unit tests live in `server/src/validate.rs`; this file
|
|
//! tests the *integration* — the wiring, not the predicate.
|
|
|
|
mod common;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use tower::util::ServiceExt;
|
|
|
|
use doctate_common::UPLOAD_PATH;
|
|
|
|
use common::{
|
|
TestConfig, body_string, extract_session_cookie, login_request, multipart_upload_body,
|
|
test_user, test_user_with_password,
|
|
};
|
|
|
|
const API_KEY: &str = "key-dr_test";
|
|
|
|
fn upload_request(content_type: &str, body: Vec<u8>) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(UPLOAD_PATH)
|
|
.header("Content-Type", content_type)
|
|
.header("X-API-Key", API_KEY)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Drift like the Wear OS bug (microseconds) must be rejected with 400
|
|
/// at the upload boundary, not silently accepted into the filename.
|
|
#[tokio::test]
|
|
async fn upload_rejects_microsecond_recorded_at_with_400() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_test"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (ct, body) = multipart_upload_body(case_id, "2026-04-27T10:34:12.987072Z", b"audio");
|
|
|
|
let resp = app.oneshot(upload_request(&ct, body)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
/// RFC3339 with offset (`+00:00`) is technically valid time, but our
|
|
/// filename pipeline is `Z`-only — we reject anything else, even if a
|
|
/// future client thinks it's being clever.
|
|
#[tokio::test]
|
|
async fn upload_rejects_offset_form_recorded_at_with_400() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_test"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (ct, body) = multipart_upload_body(case_id, "2026-04-27T10:34:12+00:00", b"audio");
|
|
|
|
let resp = app.oneshot(upload_request(&ct, body)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
/// Sanity guard: the strict shape we *do* accept still works, so the
|
|
/// validator hasn't accidentally locked everything out.
|
|
#[tokio::test]
|
|
async fn upload_accepts_strict_recorded_at_shape() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_test"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (ct, body) = multipart_upload_body(case_id, "2026-04-15T10:32:00Z", b"audio");
|
|
|
|
let resp = app.oneshot(upload_request(&ct, body)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
/// A path-traversal-shaped slug must fold into the same "Login
|
|
/// fehlgeschlagen" response as a wrong password — *not* leak via 400
|
|
/// that the slug shape was the problem.
|
|
#[tokio::test]
|
|
async fn login_with_traversal_slug_returns_login_failed_page() {
|
|
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("../etc/passwd", "anything"))
|
|
.await
|
|
.unwrap();
|
|
// Same shape as wrong-password: 200 OK with the error page, no cookie.
|
|
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}");
|
|
}
|
|
|
|
/// Uppercase / non-ASCII slugs likewise must not bypass shape rules,
|
|
/// even if they happen to coincide with no configured user.
|
|
#[tokio::test]
|
|
async fn login_with_uppercase_slug_returns_login_failed_page() {
|
|
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::OK);
|
|
assert!(extract_session_cookie(&resp).is_none());
|
|
}
|
|
|
|
/// A malformed magic-link token must not 400 (would leak shape) — it
|
|
/// 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()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_a"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
// Too short — would never have come from `generate_token`.
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/magic?token=abc")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
let loc = resp
|
|
.headers()
|
|
.get(header::LOCATION)
|
|
.expect("redirect must set Location")
|
|
.to_str()
|
|
.unwrap();
|
|
assert_eq!(loc, "/login");
|
|
}
|
|
|
|
/// And the same for tokens that pass the length check but contain
|
|
/// bytes outside the URL-safe alphabet.
|
|
#[tokio::test]
|
|
async fn magic_consume_with_non_urlsafe_token_redirects_to_login() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_a"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let bad = "%2E%2E%2Fetc%2Fpasswd%2E%2E%2Fetc"; // url-encoded ./../etc/passwd…
|
|
let uri = format!("/magic?token={bad}");
|
|
let resp = app
|
|
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
}
|