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.
161 lines
5.5 KiB
Rust
161 lines
5.5 KiB
Rust
//! HTTP request builders and response extraction helpers.
|
|
//!
|
|
//! The stock axum `Request::builder()…oneshot()` chain is verbose;
|
|
//! centralising the common shapes here turns ~8 repetitive lines per
|
|
//! call site into one function call, and localises URL/header shape
|
|
//! changes to this file.
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, header};
|
|
use axum::response::Response;
|
|
|
|
use doctate_common::{
|
|
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
|
|
};
|
|
|
|
/// Plain form-urlencoded POST with a session cookie. `body` is passed
|
|
/// verbatim; CSRF-protected calls should use [`csrf_form_post`] to add
|
|
/// the token automatically.
|
|
pub fn form_post<U: AsRef<str>>(uri: U, cookie: &str, body: impl Into<String>) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri.as_ref())
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body.into()))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically.
|
|
/// Pass an empty `extra_body` when the form has no other fields
|
|
/// (e.g. POST /cases/{id}/close).
|
|
pub fn csrf_form_post<U: AsRef<str>>(
|
|
uri: U,
|
|
cookie: &str,
|
|
csrf: &str,
|
|
extra_body: &str,
|
|
) -> Request<Body> {
|
|
let body = if extra_body.is_empty() {
|
|
format!("csrf_token={csrf}")
|
|
} else {
|
|
format!("{extra_body}&csrf_token={csrf}")
|
|
};
|
|
form_post(uri, cookie, body)
|
|
}
|
|
|
|
/// 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")
|
|
.uri(uri.as_ref())
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
/// GET with `X-API-Key` — the public `/api/*` surface used by the
|
|
/// watch/desktop clients.
|
|
pub fn get_with_api_key<U: AsRef<str>>(uri: U, api_key: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(uri.as_ref())
|
|
.header(API_KEY_HEADER, api_key)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
/// GET with `X-API-Key` and a conditional `If-None-Match` header. Used
|
|
/// by the oneliners ETag tests and the upload watermark-invalidation
|
|
/// tests.
|
|
pub fn get_with_api_key_if_none_match<U: AsRef<str>>(
|
|
uri: U,
|
|
api_key: &str,
|
|
if_none_match: &str,
|
|
) -> Request<Body> {
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(uri.as_ref())
|
|
.header(API_KEY_HEADER, api_key)
|
|
.header(header::IF_NONE_MATCH, if_none_match)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
/// Consume a response body into raw bytes.
|
|
pub async fn body_bytes(response: Response) -> Vec<u8> {
|
|
axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap()
|
|
.to_vec()
|
|
}
|
|
|
|
/// Consume a response body into a UTF-8 string. Panics on non-UTF-8 —
|
|
/// which is correct for the HTML/JSON responses these tests assert on.
|
|
pub async fn body_string(response: Response) -> String {
|
|
String::from_utf8(body_bytes(response).await).unwrap()
|
|
}
|
|
|
|
/// Consume a response body into a `serde_json::Value`. Panics on
|
|
/// malformed JSON, which the test should want to surface loudly.
|
|
pub async fn body_json(response: Response) -> serde_json::Value {
|
|
let bytes = body_bytes(response).await;
|
|
serde_json::from_slice(&bytes).unwrap()
|
|
}
|
|
|
|
/// Borrow a header value as a `&str`. Returns `None` if the header is
|
|
/// absent or not valid UTF-8.
|
|
pub fn header_opt<'a>(response: &'a Response, name: &str) -> Option<&'a str> {
|
|
response.headers().get(name).and_then(|v| v.to_str().ok())
|
|
}
|
|
|
|
/// Header value as an owned `String`, `""` if missing. Matches the
|
|
/// existing style in `oneliners_api_test.rs`'s `header_str` helper.
|
|
pub fn header_str(response: &Response, name: &str) -> String {
|
|
header_opt(response, name).unwrap_or("").to_owned()
|
|
}
|
|
|
|
/// Build a multipart body for `POST /api/upload`. Returns
|
|
/// `(content_type_header_value, body_bytes)` — pass the first as the
|
|
/// `Content-Type` header verbatim.
|
|
///
|
|
/// Uses the server's canonical field names (`case_id`, `recorded_at`,
|
|
/// `audio`) from `doctate_common::constants`, so a server-side rename
|
|
/// propagates here for free.
|
|
pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
|
|
let boundary = "----testboundary";
|
|
let mut body = Vec::new();
|
|
|
|
// case_id
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
format!("Content-Disposition: form-data; name=\"{FIELD_CASE_ID}\"\r\n\r\n").as_bytes(),
|
|
);
|
|
body.extend_from_slice(case_id.as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// recorded_at
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
format!("Content-Disposition: form-data; name=\"{FIELD_RECORDED_AT}\"\r\n\r\n").as_bytes(),
|
|
);
|
|
body.extend_from_slice(recorded_at.as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// audio
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
format!(
|
|
"Content-Disposition: form-data; name=\"{FIELD_AUDIO}\"; filename=\"test.m4a\"\r\n"
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
body.extend_from_slice(format!("Content-Type: {CONTENT_TYPE_AUDIO_MP4}\r\n\r\n").as_bytes());
|
|
body.extend_from_slice(audio);
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// end
|
|
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
|
|
|
(format!("multipart/form-data; boundary={boundary}"), body)
|
|
}
|