use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use rustio_core::admin::Admin;
use rustio_core::auth::{self, authenticate, ROLE_ADMIN};
use rustio_core::defaults::with_defaults;
use rustio_core::{Db, Router, Server};
async fn spawn_test_server() -> SocketAddr {
let db = Db::memory().await.expect("db");
auth::ensure_core_tables(&db).await.expect("tables");
auth::user::create(&db, "admin@example.com", "hunter2", ROLE_ADMIN)
.await
.expect("seed admin");
let router = with_defaults(Router::new()).wrap(authenticate(db.clone()));
let router = Admin::new().register(router, &db);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
let _ = Server::serve_router_on(listener, router).await;
});
tokio::time::sleep(Duration::from_millis(20)).await;
addr
}
async fn send(addr: SocketAddr, request: &str) -> String {
let mut stream = TcpStream::connect(addr).await.expect("connect");
let _ = stream.write_all(request.as_bytes()).await;
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf).await;
String::from_utf8_lossy(&buf).into_owned()
}
fn status_of(resp: &str) -> u16 {
let first = resp.lines().next().expect("response is empty");
first
.split_whitespace()
.nth(1)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or_else(|| panic!("could not parse status from: {first}"))
}
fn extract_cookie(resp: &str, name: &str) -> Option<String> {
let headers_end = resp.find("\r\n\r\n").unwrap_or(resp.len());
let headers = &resp[..headers_end];
for line in headers.lines() {
let line = line.trim();
let Some(value) = line
.strip_prefix("set-cookie: ")
.or_else(|| line.strip_prefix("Set-Cookie: "))
else {
continue;
};
if let Some(first) = value.split(';').next() {
if let Some((k, v)) = first.split_once('=') {
if k == name {
return Some(v.to_string());
}
}
}
}
None
}
fn form_post(path: &str, body: &str) -> String {
format!(
"POST {path} HTTP/1.1\r\n\
Host: test.local\r\n\
Connection: close\r\n\
Content-Type: application/x-www-form-urlencoded\r\n\
Content-Length: {len}\r\n\
\r\n\
{body}",
len = body.len(),
)
}
fn form_post_with_cookie(path: &str, body: &str, cookie: &str) -> String {
format!(
"POST {path} HTTP/1.1\r\n\
Host: test.local\r\n\
Connection: close\r\n\
Cookie: rustio_session={cookie}\r\n\
Content-Type: application/x-www-form-urlencoded\r\n\
Content-Length: {len}\r\n\
\r\n\
{body}",
len = body.len(),
)
}
fn get_with_cookie(path: &str, cookie: &str) -> String {
format!(
"GET {path} HTTP/1.1\r\n\
Host: test.local\r\n\
Connection: close\r\n\
Cookie: rustio_session={cookie}\r\n\
\r\n"
)
}
fn extract_csrf(html: &str) -> Option<String> {
for input in html.split("<input") {
if !input.contains(r#"name="_csrf""#) {
continue;
}
if let Some(value_start) = input.find("value=\"") {
let rest = &input[value_start + "value=\"".len()..];
if let Some(end) = rest.find('"') {
return Some(rest[..end].to_string());
}
}
}
None
}
#[tokio::test]
async fn full_login_flow_admin_cookie_auth_logout() {
let addr = spawn_test_server().await;
let resp = send(
addr,
"GET /admin HTTP/1.1\r\nHost: test.local\r\nConnection: close\r\n\r\n",
)
.await;
assert_eq!(status_of(&resp), 401, "anonymous /admin must be 401");
let resp = send(
addr,
&form_post("/admin/login", "email=admin@example.com&password=WRONG"),
)
.await;
assert_eq!(status_of(&resp), 401);
assert!(
resp.contains("Invalid email or password"),
"wrong password must use the generic credential error"
);
let resp = send(
addr,
&form_post("/admin/login", "email=ghost@example.com&password=whatever"),
)
.await;
assert_eq!(status_of(&resp), 401);
assert!(resp.contains("Invalid email or password"));
let resp = send(
addr,
&form_post("/admin/login", "email=admin@example.com&password=hunter2"),
)
.await;
assert_eq!(
status_of(&resp),
303,
"successful login must redirect; response was:\n{resp}"
);
let token = extract_cookie(&resp, "rustio_session")
.unwrap_or_else(|| panic!("session cookie not set; response was:\n{resp}"));
assert!(!token.is_empty());
assert!(
resp.to_lowercase().contains("httponly"),
"session cookie must be HttpOnly"
);
assert!(
resp.contains("SameSite=Strict"),
"session cookie must be SameSite=Strict"
);
let resp = send(addr, &get_with_cookie("/admin", &token)).await;
assert_eq!(status_of(&resp), 200, "cookie must grant admin access");
let csrf = extract_csrf(&resp).expect("admin index must render _csrf input");
assert!(!csrf.is_empty());
let lower = resp.to_lowercase();
assert!(
lower.contains("x-frame-options: deny"),
"admin responses must ship X-Frame-Options: DENY"
);
assert!(
lower.contains("x-content-type-options: nosniff"),
"admin responses must ship X-Content-Type-Options: nosniff"
);
assert!(
lower.contains("referrer-policy: no-referrer"),
"admin responses must ship Referrer-Policy: no-referrer"
);
assert!(
!lower.contains("strict-transport-security"),
"dev mode must not emit HSTS"
);
let resp = send(addr, &form_post_with_cookie("/admin/logout", "", &token)).await;
assert_eq!(
status_of(&resp),
403,
"logout without CSRF must be rejected; response was:\n{resp}",
);
let logout_body = format!("_csrf={csrf}");
let resp = send(
addr,
&form_post_with_cookie("/admin/logout", &logout_body, &token),
)
.await;
assert_eq!(status_of(&resp), 303);
assert!(
resp.contains("Max-Age=0"),
"logout must emit a Max-Age=0 cookie"
);
let resp = send(addr, &get_with_cookie("/admin", &token)).await;
assert_eq!(
status_of(&resp),
401,
"replayed token after logout must be rejected"
);
}
#[tokio::test]
async fn oversized_form_body_returns_413() {
let addr = spawn_test_server().await;
let big = "a".repeat(3 * 1024 * 1024);
let resp = send(addr, &form_post("/admin/login", &big)).await;
assert_eq!(
status_of(&resp),
413,
"oversized form bodies must be rejected with 413"
);
}
#[tokio::test]
async fn logout_without_csrf_returns_403() {
let addr = spawn_test_server().await;
let resp = send(
addr,
&form_post("/admin/login", "email=admin@example.com&password=hunter2"),
)
.await;
assert_eq!(status_of(&resp), 303);
let token = extract_cookie(&resp, "rustio_session").unwrap();
let resp = send(addr, &form_post_with_cookie("/admin/logout", "", &token)).await;
assert_eq!(
status_of(&resp),
403,
"logout without _csrf must be 403; response was:\n{resp}",
);
let resp = send(
addr,
&form_post_with_cookie("/admin/logout", "_csrf=not-the-token", &token),
)
.await;
assert_eq!(
status_of(&resp),
403,
"logout with wrong _csrf must be 403; response was:\n{resp}",
);
let resp = send(addr, &get_with_cookie("/admin", &token)).await;
assert_eq!(
status_of(&resp),
200,
"failed CSRF logout must not delete the underlying session",
);
}
#[tokio::test]
async fn anonymous_post_admin_logout_is_rejected() {
let addr = spawn_test_server().await;
let resp = send(addr, &form_post("/admin/logout", "_csrf=anything")).await;
assert_eq!(
status_of(&resp),
403,
"unauthenticated logout POST must be 403",
);
}
#[tokio::test]
async fn global_body_limit_rejects_large_non_admin_post() {
let addr = spawn_test_server().await;
let big = "a".repeat(3 * 1024 * 1024);
let resp = send(addr, &form_post("/", &big)).await;
assert_eq!(
status_of(&resp),
413,
"oversized body on ANY route must be 413"
);
}
#[tokio::test]
async fn admin_response_headers_are_present() {
let addr = spawn_test_server().await;
let resp = send(
addr,
"GET /admin HTTP/1.1\r\nHost: test.local\r\nConnection: close\r\n\r\n",
)
.await;
assert_eq!(status_of(&resp), 401);
let lower = resp.to_lowercase();
assert!(lower.contains("x-frame-options: deny"));
assert!(lower.contains("x-content-type-options: nosniff"));
assert!(lower.contains("referrer-policy: no-referrer"));
}
#[tokio::test]
async fn login_rate_limiter_triggers_lockout() {
let addr = spawn_test_server().await;
let email = "ratelimit-probe@example.com";
for _ in 0..5 {
let resp = send(
addr,
&form_post("/admin/login", &format!("email={email}&password=WRONG")),
)
.await;
assert_eq!(status_of(&resp), 401);
}
let resp = send(
addr,
&form_post("/admin/login", &format!("email={email}&password=WRONG")),
)
.await;
assert_eq!(
status_of(&resp),
429,
"sixth failed attempt must trip the rate limiter"
);
assert!(resp.contains("Too many failed attempts"));
}