2e3b5efe86
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.
Shape:
- New askama macro partials/csrf_field.html renders the hidden
input; 3 templates import + {% call csrf::field(csrf_token) %}
inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
session once during extraction) so render handlers don't need a
second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
CaseRecordingsTemplate) gain the field; render sites pass
user.csrf_token through.
Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
a hidden input — catches anyone adding a new form without the
macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
with token → 303. Catches drift between rendered and accepted
token formats.
551 lines
19 KiB
Rust
551 lines
19 KiB
Rust
//! Attack-confirming tests for CSRF protection on /web/ state-changing
|
|
//! endpoints.
|
|
//!
|
|
//! Each test crafts a request that simulates a real attack shape
|
|
//! (cross-site POST with valid cookie, wrong token, empty token, etc.)
|
|
//! and asserts the expected defense. The defense is implemented by
|
|
//! the `CsrfForm<T>` extractor in `server/src/csrf.rs`, validated
|
|
//! against `WebSession::csrf_token`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use tower::util::ServiceExt;
|
|
|
|
use doctate_server::config::{Config, User};
|
|
|
|
// ---------- fixtures ----------
|
|
|
|
fn make_user(slug: &str, password: &str, role: &str) -> User {
|
|
User {
|
|
slug: slug.into(),
|
|
api_key: format!("key-{slug}"),
|
|
web_password: bcrypt::hash(password, 4).unwrap(),
|
|
role: role.into(),
|
|
whisper: Default::default(),
|
|
retention: Default::default(),
|
|
window_hours: 72,
|
|
preview_lines: 2,
|
|
}
|
|
}
|
|
|
|
fn test_config_with(users: Vec<User>) -> Arc<Config> {
|
|
let data_path = std::env::temp_dir().join(format!(
|
|
"doctate-csrf-test-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let api_keys: HashMap<String, String> = users
|
|
.iter()
|
|
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
|
.collect();
|
|
Arc::new(Config {
|
|
data_path,
|
|
users,
|
|
api_keys,
|
|
..Config::test_default()
|
|
})
|
|
}
|
|
|
|
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
|
|
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
|
let s = v.to_str().ok()?;
|
|
if let Some(pair) = s.split(';').next()
|
|
&& pair.starts_with("session=")
|
|
{
|
|
return Some(pair.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn login_request(slug: &str, password: &str) -> Request<Body> {
|
|
let body = format!("slug={slug}&password={password}");
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/login")
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
async fn login_as(app: &axum::Router, slug: &str, password: &str) -> String {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(login_request(slug, password))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
|
|
extract_session_cookie(&resp).expect("no session cookie after login")
|
|
}
|
|
|
|
fn form_post(path: &str, cookie: &str, body: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(path)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::from(body.to_owned()))
|
|
.unwrap()
|
|
}
|
|
|
|
// ---------- session hygiene (green today) ----------
|
|
|
|
/// Session-fixation: attacker sets a known `session=...` cookie on the
|
|
/// victim's browser. If the server reused that value after login, the
|
|
/// attacker would be logged in as the victim. Defense: every successful
|
|
/// login mints a fresh token; the pre-set value must be overwritten.
|
|
#[tokio::test]
|
|
async fn session_fixation_login_rotates_cookie() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "secret", "doctor")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let attacker_fixed_value = "session=attacker-fixed-token-value-aaaaaaaaaaaaaaaa";
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/login")
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.header(header::COOKIE, attacker_fixed_value)
|
|
.body(Body::from("slug=dr_a&password=secret"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
|
|
let minted = extract_session_cookie(&resp).expect("no Set-Cookie on login");
|
|
assert!(
|
|
minted != attacker_fixed_value,
|
|
"login did not rotate the cookie — session fixation possible"
|
|
);
|
|
assert!(
|
|
minted.starts_with("session="),
|
|
"Set-Cookie shape unexpected: {minted}"
|
|
);
|
|
// Token portion has the full 43-char entropy, not the attacker's value.
|
|
let minted_token = minted.trim_start_matches("session=");
|
|
assert!(
|
|
minted_token.len() >= 40,
|
|
"minted token looks truncated: {minted_token:?}"
|
|
);
|
|
}
|
|
|
|
// ---------- CSRF defense — missing token ----------
|
|
|
|
#[tokio::test]
|
|
async fn bulk_without_csrf_token_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000";
|
|
let resp = app
|
|
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"bulk POST without csrf_token should be 403 — got {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn purge_closed_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post("/web/cases/purge-closed", &cookie, "confirm=yes"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post(
|
|
"/web/cases/11111111-1111-1111-1111-111111111111/reset",
|
|
&cookie,
|
|
"",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn close_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post(
|
|
"/web/cases/11111111-1111-1111-1111-111111111111/close",
|
|
&cookie,
|
|
"",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reopen_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post(
|
|
"/web/cases/11111111-1111-1111-1111-111111111111/reopen",
|
|
&cookie,
|
|
"",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post(
|
|
"/web/cases/11111111-1111-1111-1111-111111111111/analyze",
|
|
&cookie,
|
|
"",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_recording_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post(
|
|
"/web/cases/11111111-1111-1111-1111-111111111111/recordings/delete",
|
|
&cookie,
|
|
"filename=2026-04-14T10-00-00Z.m4a",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the
|
|
/// victim out of their active session (annoyance / phishing setup where
|
|
/// victim re-enters password on a lookalike page).
|
|
#[tokio::test]
|
|
async fn logout_without_csrf_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_a", "s").await;
|
|
|
|
let resp = app
|
|
.oneshot(form_post("/web/logout", &cookie, ""))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"forced-logout CSRF not blocked"
|
|
);
|
|
}
|
|
|
|
// ---------- CSRF defense — malformed token values ----------
|
|
|
|
#[tokio::test]
|
|
async fn bulk_with_wrong_csrf_token_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
// A 43-char alphanumeric that is structurally valid but does not
|
|
// match the session's token.
|
|
let wrong = "a".repeat(43);
|
|
let body =
|
|
format!("action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}");
|
|
let resp = app
|
|
.oneshot(form_post("/web/cases/bulk", &cookie, &body))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_with_empty_csrf_token_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=";
|
|
let resp = app
|
|
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"empty csrf_token must not bypass the check"
|
|
);
|
|
}
|
|
|
|
/// Whitespace trick: some frameworks trim tokens. If the server trimmed,
|
|
/// an attacker could send `csrf_token=%0A%0A` and hit an early-return
|
|
/// `if token.is_empty() { skip }` branch. Defense: compare raw.
|
|
#[tokio::test]
|
|
async fn bulk_with_whitespace_padded_token_forbidden() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
// Leading+trailing spaces around a value that *would* match post-trim.
|
|
// Even if the real token were "VALID", this should still fail.
|
|
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20";
|
|
let resp = app
|
|
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
/// Paranoia: token-field value is a SQL-injection-looking string. The
|
|
/// server uses a HashMap, so SQL is a non-issue — this test just pins
|
|
/// down that the check returns a clean 403 rather than 500/panic on
|
|
/// unusual byte content.
|
|
#[tokio::test]
|
|
async fn bulk_with_injection_payload_returns_403_not_500() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = login_as(&app, "dr_admin", "s").await;
|
|
|
|
// `' OR 1=1--` URL-encoded.
|
|
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000\
|
|
&csrf_token=%27+OR+1%3D1--";
|
|
let resp = app
|
|
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"malformed token must yield 403, not 500"
|
|
);
|
|
}
|
|
|
|
// ---------- template rendering: the token must reach the browser ----------
|
|
//
|
|
// The server-side CSRF check is useless if the templates forget to emit
|
|
// the hidden field — the legitimate user would get 403 on every button.
|
|
// These tests GET each authenticated page, grab the session's real
|
|
// csrf_token from the store, and assert it appears verbatim in a
|
|
// hidden input in the rendered HTML. If anyone adds a new state-
|
|
// changing form without the {% call csrf::field(csrf_token) %} macro,
|
|
// the browser flow breaks and one of these fires.
|
|
|
|
async fn login_and_get_csrf(
|
|
app: &axum::Router,
|
|
store: &doctate_server::web_session::SessionStore,
|
|
slug: &str,
|
|
password: &str,
|
|
) -> (String, String) {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(login_request(slug, password))
|
|
.await
|
|
.unwrap();
|
|
let cookie = extract_session_cookie(&resp).expect("login cookie");
|
|
let csrf = store
|
|
.read()
|
|
.await
|
|
.get(cookie.trim_start_matches("session="))
|
|
.unwrap()
|
|
.csrf_token
|
|
.clone();
|
|
(cookie, csrf)
|
|
}
|
|
|
|
async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(uri)
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
|
|
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
String::from_utf8(bytes.to_vec()).unwrap()
|
|
}
|
|
|
|
fn hidden_field(token: &str) -> String {
|
|
format!(r#"name="csrf_token" value="{token}""#)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn my_cases_page_renders_csrf_token_hidden_field() {
|
|
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
|
// Seed a case so the bulk form is rendered (the template skips it
|
|
// when the list is empty) — otherwise only the logout form shows.
|
|
let case_dir = cfg
|
|
.data_path
|
|
.join("dr_admin/11111111-1111-1111-1111-111111111111");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
|
let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_admin", "s").await;
|
|
|
|
let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await;
|
|
let expected = hidden_field(&csrf);
|
|
|
|
// Logout form must carry the token.
|
|
assert!(
|
|
body.contains(&expected),
|
|
"/web/cases should render hidden csrf_token ({expected}), \
|
|
first 200 chars of body: {}",
|
|
&body[..body.len().min(200)]
|
|
);
|
|
// Logout form must specifically have it (present in the body).
|
|
assert!(
|
|
body.contains(r#"action="/web/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()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn case_page_renders_csrf_token_hidden_field() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
// Seed a minimal case so GET /web/cases/{id} returns 200 instead of 404.
|
|
let case_dir = cfg.data_path.join("dr_a").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
|
let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await;
|
|
let expected = hidden_field(&csrf);
|
|
|
|
assert!(
|
|
body.contains(&expected),
|
|
"case page must render hidden csrf_token for close/analyze forms"
|
|
);
|
|
// Close form plus logout form → at least 2 hidden fields.
|
|
assert!(
|
|
body.matches(r#"name="csrf_token""#).count() >= 2,
|
|
"case page should have ≥2 csrf_token inputs; got {}",
|
|
body.matches(r#"name="csrf_token""#).count()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn case_recordings_page_renders_csrf_token_hidden_field() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = cfg.data_path.join("dr_a").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
|
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.transcript.txt"), b"hi").unwrap();
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
|
let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
let body = html_body(&app, &format!("/web/cases/{case_id}/recordings"), &cookie).await;
|
|
let expected = hidden_field(&csrf);
|
|
|
|
assert!(
|
|
body.contains(&expected),
|
|
"recordings page must render hidden csrf_token for the delete form"
|
|
);
|
|
// Logout form + one delete form per recording (one here) ≥ 2.
|
|
assert!(
|
|
body.matches(r#"name="csrf_token""#).count() >= 2,
|
|
"recordings page should have ≥2 csrf_token inputs; got {}",
|
|
body.matches(r#"name="csrf_token""#).count()
|
|
);
|
|
}
|
|
|
|
/// Full round-trip: fetch the page, extract the csrf_token from the
|
|
/// rendered HTML, POST with that token — expect 303 (success). This is
|
|
/// the legitimate-user path; if the server-side check or the template
|
|
/// ever drift out of sync, this test catches it. Protects against the
|
|
/// subtle regression where the token is rendered but with a value the
|
|
/// server won't accept (e.g. accidental trim, base64-encoded, etc.).
|
|
#[tokio::test]
|
|
async fn happy_path_post_with_rendered_token_accepted() {
|
|
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = cfg.data_path.join("dr_a").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
|
let (cookie, session_csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
// Render the case page, parse the csrf_token value out of any
|
|
// hidden input — do NOT use the session store directly; this tests
|
|
// the *rendered* token specifically.
|
|
let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await;
|
|
let needle = r#"name="csrf_token" value=""#;
|
|
let start = body.find(needle).expect("no csrf_token in HTML") + needle.len();
|
|
let end = body[start..]
|
|
.find('"')
|
|
.expect("unterminated csrf_token value")
|
|
+ start;
|
|
let rendered = &body[start..end];
|
|
|
|
// Rendered token must be the session's actual token. If these ever
|
|
// diverge we want the test to fail loudly.
|
|
assert_eq!(
|
|
rendered, session_csrf,
|
|
"rendered csrf_token must match session's csrf_token"
|
|
);
|
|
|
|
// POST close with the rendered token → must succeed.
|
|
let resp = app
|
|
.oneshot(form_post(
|
|
&format!("/web/cases/{case_id}/close"),
|
|
&cookie,
|
|
&format!("csrf_token={rendered}"),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::SEE_OTHER,
|
|
"legitimate POST with rendered token must be accepted (303)"
|
|
);
|
|
}
|