use scraper::{Html, Selector};
use tracing::instrument;
use crate::client::Client;
use crate::error::{ClientError, Result};
use crate::session::Session;
const EXECUTIVE_SUMMARY_PATH: &str = "/ExecutiveSummary";
const XSRF_INPUT_NAME: &str = "__RequestVerificationToken";
#[instrument(skip_all)]
pub async fn extract_xsrf_token(client: &Client) -> Result<String> {
let body = client.get(EXECUTIVE_SUMMARY_PATH).await?;
parse_xsrf_token(&body)
}
pub fn is_login_page(url: &str, body: &str) -> bool {
url.to_ascii_lowercase().contains("/login") || body.contains(r#"type="password""#)
}
#[instrument(skip_all)]
pub async fn refresh_session(client: &Client, session: &Session) -> Result<Session> {
let xsrf_token = extract_xsrf_token(client).await?;
Ok(Session::new(session.cookies().to_vec(), xsrf_token))
}
fn parse_xsrf_token(html: &str) -> Result<String> {
let document = Html::parse_document(html);
let selector = Selector::parse(r#"input[name="__RequestVerificationToken"]"#)
.expect("hardcoded CSS selector is valid");
let input =
document
.select(&selector)
.next()
.ok_or_else(|| ClientError::UnexpectedContent {
expected: format!("hidden {XSRF_INPUT_NAME} input"),
actual: "[REDACTED XSRF]".to_string(),
url: EXECUTIVE_SUMMARY_PATH.to_string(),
})?;
let value = input.value().attr("value").unwrap_or("");
if value.is_empty() {
return Err(ClientError::UnexpectedContent {
expected: format!("non-empty {XSRF_INPUT_NAME} value"),
actual: "[REDACTED XSRF]".to_string(),
url: EXECUTIVE_SUMMARY_PATH.to_string(),
});
}
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::ClientConfig;
use crate::session::{COOKIE_DOMAIN, Cookie, FORMS_AUTH_COOKIE_NAME, SESSION_COOKIE_NAME};
fn valid_session() -> Session {
Session::new(
vec![
Cookie::new(SESSION_COOKIE_NAME, "session-123", COOKIE_DOMAIN),
Cookie::new(FORMS_AUTH_COOKIE_NAME, "auth-456", COOKIE_DOMAIN),
],
"xsrf-789",
)
}
fn test_config(server: &mockito::Server) -> ClientConfig {
ClientConfig {
base_url: server.url(),
..ClientConfig::default()
}
}
#[test]
fn parse_xsrf_token_extracts_value() {
let html = r#"<html><input name="__RequestVerificationToken" type="hidden" value="xsrf-123"></html>"#;
assert_eq!(parse_xsrf_token(html).unwrap(), "xsrf-123");
}
#[test]
fn parse_xsrf_token_handles_reversed_attributes() {
let html = r#"<input type="hidden" value="xsrf-456" name="__RequestVerificationToken">"#;
assert_eq!(parse_xsrf_token(html).unwrap(), "xsrf-456");
}
#[test]
fn parse_xsrf_token_rejects_missing_input() {
let html = "<html><body>no token here</body></html>";
let err = parse_xsrf_token(html).unwrap_err();
assert!(matches!(err, ClientError::UnexpectedContent { .. }));
}
#[test]
fn parse_xsrf_token_rejects_empty_value() {
let html = r#"<input name="__RequestVerificationToken" value="">"#;
let err = parse_xsrf_token(html).unwrap_err();
assert!(matches!(err, ClientError::UnexpectedContent { .. }));
}
#[test]
fn parse_xsrf_token_rejects_missing_value_attribute() {
let html = r#"<input name="__RequestVerificationToken">"#;
let err = parse_xsrf_token(html).unwrap_err();
assert!(matches!(err, ClientError::UnexpectedContent { .. }));
}
#[test]
fn is_login_page_url_contains_login() {
assert!(is_login_page("/login", ""));
}
#[test]
fn is_login_page_url_case_insensitive() {
assert!(is_login_page("/Login/Account", ""));
}
#[test]
fn is_login_page_body_contains_password_input() {
assert!(is_login_page(
"/other",
r#"<input type="password" name="Password">"#
));
}
#[test]
fn is_login_page_normal_page_returns_false() {
assert!(!is_login_page("/home", "<p>Hello</p>"));
}
#[test]
fn is_login_page_incidental_login_string_ignored() {
assert!(!is_login_page(
"/ExecutiveSummary",
r#"<script>if ("ExecutiveSummary" != "Login") { init(); }</script>"#
));
}
#[tokio::test]
async fn extract_xsrf_token_happy_path() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/ExecutiveSummary")
.with_status(200)
.with_body(
r#"<html><input name="__RequestVerificationToken" type="hidden" value="xsrf-123"></html>"#,
)
.create_async()
.await;
let client = Client::with_config(valid_session(), test_config(&server)).unwrap();
let token = extract_xsrf_token(&client).await.unwrap();
assert_eq!(token, "xsrf-123");
mock.assert_async().await;
}
#[tokio::test]
async fn extract_xsrf_token_missing_input_returns_unexpected_content() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/ExecutiveSummary")
.with_status(200)
.with_body("<html></html>")
.create_async()
.await;
let client = Client::with_config(valid_session(), test_config(&server)).unwrap();
let err = extract_xsrf_token(&client).await.unwrap_err();
assert!(matches!(err, ClientError::UnexpectedContent { .. }));
}
#[tokio::test]
async fn extract_xsrf_token_login_redirect_returns_session_expired() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/ExecutiveSummary")
.with_status(302)
.with_header("location", "/login")
.create_async()
.await;
server
.mock("GET", "/login")
.with_status(200)
.with_body(r#"<html><input type="password"></html>"#)
.create_async()
.await;
let client = Client::with_config(valid_session(), test_config(&server)).unwrap();
let err = extract_xsrf_token(&client).await.unwrap_err();
assert!(err.is_session_expired());
}
#[tokio::test]
async fn extract_xsrf_token_login_page_body_returns_session_expired() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/ExecutiveSummary")
.with_status(200)
.with_body(
r#"<html><form action="/Login/Login"><input type="password" name="Password"></form></html>"#,
)
.create_async()
.await;
let client = Client::with_config(valid_session(), test_config(&server)).unwrap();
let err = extract_xsrf_token(&client).await.unwrap_err();
assert!(err.is_session_expired());
}
#[tokio::test]
async fn extract_xsrf_token_ignores_incidental_login_string() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/ExecutiveSummary")
.with_status(200)
.with_body(
r#"<html><body>
<input name="__RequestVerificationToken" type="hidden" value="xsrf-ok">
<script>if ("ExecutiveSummary" != "Login") { init(); }</script>
</body></html>"#,
)
.create_async()
.await;
let client = Client::with_config(valid_session(), test_config(&server)).unwrap();
let token = extract_xsrf_token(&client).await.unwrap();
assert_eq!(token, "xsrf-ok");
}
#[tokio::test]
async fn refresh_session_returns_session_with_new_token() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/ExecutiveSummary")
.with_status(200)
.with_body(
r#"<html><input name="__RequestVerificationToken" type="hidden" value="new-xsrf"></html>"#,
)
.create_async()
.await;
let session = valid_session();
let client = Client::with_config(session.clone(), test_config(&server)).unwrap();
let new_session = refresh_session(&client, &session).await.unwrap();
assert_eq!(new_session.xsrf_token(), "new-xsrf");
assert_eq!(new_session.cookies().len(), session.cookies().len());
}
#[test]
fn error_messages_never_contain_xsrf_token() {
let html = r#"<html><input name="__RequestVerificationToken" type="hidden" value="super-secret-xsrf"></html>"#;
let token = parse_xsrf_token(html).unwrap();
assert_eq!(token, "super-secret-xsrf");
let missing_err = parse_xsrf_token("<html></html>").unwrap_err();
let debug = format!("{missing_err:?}");
let display = missing_err.to_string();
assert!(
!debug.contains("super-secret"),
"Debug must not contain token value"
);
assert!(
!display.contains("super-secret"),
"Display must not contain token value"
);
assert!(
debug.contains("[REDACTED]"),
"Debug should show [REDACTED], got: {debug}"
);
}
}