use std::sync::Arc;
use jiff::Timestamp;
use toasty::Db;
use topcoat::{
context::Cx,
router::{Body, response::IntoResponse},
};
use super::{
login::GENERIC_ERROR,
session::{SESSION_SWEEP_BATCH, sweep_expired_sessions, token_key},
*,
};
use crate::{
Panel,
panel::{state::PanelState, test_support::mount},
};
#[test]
fn safe_next_only_accepts_same_origin_relative_paths() {
assert_eq!(safe_next("/admin/users"), Some("/admin/users"));
assert_eq!(
safe_next("/admin/posts?status=draft"),
Some("/admin/posts?status=draft")
);
assert_eq!(safe_next(" /admin "), Some("/admin"));
for target in [
"",
"admin",
"//evil.example/login",
"https://evil.example/steal",
"/\\evil.example",
"/admin\r\nLocation: https://evil.example",
] {
assert_eq!(safe_next(target), None, "must reject {target:?}");
}
}
#[test]
fn password_hashes_verify_round_trip() {
let phc = hash_password("correct horse battery staple").expect("hash");
assert!(phc.starts_with("$argon2id$"), "{phc}");
assert!(verify_password("correct horse battery staple", Some(&phc)));
assert!(!verify_password("wrong", Some(&phc)));
assert!(!verify_password("anything", Some("not-a-phc")));
assert!(!verify_password("anything", None));
}
#[test]
fn token_keys_are_hex_encoded_sha256() {
let token = topcoat::session::Token::random();
let key = token_key(&token.hash());
assert_eq!(key.len(), 64);
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
}
async fn schema_less_db() -> Db {
Db::builder()
.models(toasty::models!(AdminUser, AuthSession))
.connect("sqlite::memory:")
.await
.expect("connect to in-memory sqlite")
}
#[test]
fn infrastructure_failure_maps_driver_errors_to_the_opaque_sign_in_copy() {
let err = super::infrastructure_failure(toasty::Error::from_args(format_args!(
"secret driver gunk: no such table"
)));
let rendered = err.to_string();
assert!(
rendered.contains(UNAVAILABLE_ERROR),
"the opaque message must survive, got {rendered}"
);
assert!(
!rendered.contains("gunk"),
"driver text must not leak, got {rendered}"
);
assert!(
!rendered.contains(GENERIC_ERROR),
"an outage must not read as a rejected password, got {rendered}"
);
}
#[test]
fn infrastructure_failure_keeps_an_app_error_intact() {
let guard: topcoat::Error = topcoat::router::error::not_found().into();
assert!(
super::infrastructure_failure(guard).is::<topcoat::router::error::NotFoundError>(),
"an app-authored error must keep its own mapping"
);
}
fn login_cx(db: Db, token: &str) -> Cx {
use topcoat::{context::CxTestBuilder, cookie::CookieJarCell};
let parts = http::Request::builder()
.method(http::Method::POST)
.uri("/admin/login")
.header(
http::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.header(
http::header::COOKIE,
format!("{}={token}", crate::csrf::COOKIE_NAME),
)
.body(())
.unwrap()
.into_parts()
.0;
CxTestBuilder::new()
.app_context(db)
.request_context(crate::panel::test_support::current_panel(
crate::panel::test_support::panel_state("/admin", Auth::password()),
))
.request_context(parts)
.request_context(CookieJarCell::new())
.build()
}
async fn post_login(cx: &Cx, form: String) -> (http::StatusCode, String) {
let response = login_post(cx, Body::from(form))
.await
.expect("a failed sign-in is answered by the login page");
let status = response.status();
let body = String::from_utf8_lossy(
&http_body_util::BodyExt::collect(response.into_body())
.await
.unwrap()
.to_bytes(),
)
.to_string();
(status, body)
}
#[tokio::test]
async fn a_driver_login_failure_does_not_echo_driver_text() {
let db = schema_less_db().await;
let mut raw = db.clone();
let driver = AdminUser::filter(
AdminUser::fields()
.email()
.eq("ada@example.com".to_string()),
)
.first()
.exec(&mut raw)
.await
.expect_err("the table is missing")
.to_string();
drop(raw);
assert!(
driver.contains("no such table"),
"the control must be a driver failure, got {driver:?}"
);
let token = Uuid::new_v4().to_string();
let cx = login_cx(db, &token);
let (status, rendered) = post_login(
&cx,
format!("email=ada@example.com&password=opensesame&csrf_token={token}"),
)
.await;
assert_eq!(
status,
http::StatusCode::SERVICE_UNAVAILABLE,
"an outage is not a credential rejection"
);
assert!(
rendered.contains(UNAVAILABLE_ERROR),
"the opaque outage copy must reach the page, got {rendered:?}"
);
assert!(
!rendered.contains(&driver) && !rendered.contains("no such table"),
"driver text must not reach the response: the driver said {driver:?}, the response said {rendered:?}"
);
assert!(
!rendered.contains(GENERIC_ERROR),
"an outage must not read as a rejected password, got {rendered:?}"
);
}
#[tokio::test]
async fn a_rejected_password_still_renders_the_generic_error() {
let mut db = schema_less_db().await;
db.push_schema().await.expect("push schema");
toasty::create!(AdminUser {
email: "ada@example.com".to_string(),
password_hash: hash_password("opensesame").expect("hash"),
display_name: "Ada".to_string(),
active: true,
created_at: Timestamp::now(),
})
.exec(&mut db)
.await
.expect("seed admin");
let token = Uuid::new_v4().to_string();
let cx = login_cx(db, &token);
let (status, rendered) = post_login(
&cx,
format!("email=ada@example.com&password=wrong&csrf_token={token}"),
)
.await;
assert_eq!(
status,
http::StatusCode::FORBIDDEN,
"a rejection is not an outage"
);
assert!(
rendered.contains(GENERIC_ERROR),
"the credential copy must survive, got {rendered:?}"
);
assert!(
!rendered.contains(UNAVAILABLE_ERROR),
"a rejected password must not read as an outage, got {rendered:?}"
);
}
fn auth_router(db: Db) -> topcoat::router::Router {
mount(db, Panel::new("admin").auth(Auth::password())).expect("panel builds")
}
fn login_request(body: String, csrf: &str) -> http::Request<Body> {
http::Request::builder()
.method(http::Method::POST)
.uri("/admin/login")
.header(
http::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.header(
http::header::COOKIE,
format!("{}={csrf}", crate::csrf::COOKIE_NAME),
)
.body(Body::from(body))
.unwrap()
}
fn session_cookie(response: &http::Response<Body>) -> Option<String> {
response
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find(|value| value.starts_with("__Host-session="))
.map(str::to_string)
}
async fn db_with_admin(email: &str) -> Db {
let mut db = Db::builder()
.models(toasty::models!(AdminUser, AuthSession))
.connect("sqlite::memory:")
.await
.expect("connect to in-memory sqlite");
db.push_schema().await.expect("push schema");
toasty::create!(AdminUser {
email: email.to_string(),
password_hash: hash_password("opensesame").expect("hash"),
display_name: "Ada".to_string(),
active: true,
created_at: Timestamp::now(),
})
.exec(&mut db)
.await
.expect("seed admin");
db
}
#[tokio::test]
async fn an_oversized_login_post_is_refused() {
let db = db_with_admin("ada@example.com").await;
let router = auth_router(db);
let token = Uuid::new_v4().to_string();
let oversized = format!(
"email={}&password=opensesame&csrf_token={token}",
"a".repeat(MAX_LOGIN_BYTES)
);
let resp = router.handle(login_request(oversized, &token)).await;
assert_eq!(
resp.status(),
http::StatusCode::PAYLOAD_TOO_LARGE,
"a login body over the credential cap must be refused, got {}",
resp.status()
);
assert!(
session_cookie(&resp).is_none(),
"a refused login must not start a session"
);
let token = Uuid::new_v4().to_string();
let resp = router
.handle(login_request(
format!("email=ada@example.com&password=opensesame&csrf_token={token}"),
&token,
))
.await;
assert_eq!(
resp.status(),
http::StatusCode::SEE_OTHER,
"a normal credential POST must sign in, got {}",
resp.status()
);
assert!(
session_cookie(&resp).is_some(),
"a successful login starts a session"
);
}
#[tokio::test]
async fn login_sweeps_every_expired_session() {
let mut db = db_with_admin("ada@example.com").await;
let other = toasty::create!(AdminUser {
email: "grace@example.com".to_string(),
password_hash: hash_password("opensesame").expect("hash"),
display_name: "Grace".to_string(),
active: true,
created_at: Timestamp::now(),
})
.exec(&mut db)
.await
.expect("seed the other admin");
let ada = AdminUser::filter(
AdminUser::fields()
.email()
.eq("ada@example.com".to_string()),
)
.first()
.exec(&mut db)
.await
.expect("look up ada")
.expect("ada exists");
let expired = "2000-01-01T00:00:00Z"
.parse::<Timestamp>()
.expect("a past timestamp");
let live = "2100-01-01T00:00:00Z"
.parse::<Timestamp>()
.expect("a future timestamp");
for (token_hash, user_id, expires_at) in [
("expired-mine", ada.id.to_string(), expired),
("live-mine", ada.id.to_string(), live),
("expired-other", other.id.to_string(), expired),
] {
toasty::create!(AuthSession {
token_hash: token_hash.to_string(),
user_id,
panel: "/admin".to_string(),
expires_at,
created_at: Timestamp::now(),
})
.exec(&mut db)
.await
.expect("seed a session row");
}
let router = auth_router(db.clone());
let token = Uuid::new_v4().to_string();
let resp = router
.handle(login_request(
format!("email=ada@example.com&password=opensesame&csrf_token={token}"),
&token,
))
.await;
assert_eq!(
resp.status(),
http::StatusCode::SEE_OTHER,
"the login must succeed"
);
let mut check = db.clone();
assert!(
AuthSession::filter(
AuthSession::fields()
.token_hash()
.eq("expired-mine".to_string())
)
.first()
.exec(&mut check)
.await
.expect("query")
.is_none(),
"the signing-in user's expired session must be purged"
);
let mut check = db.clone();
assert!(
AuthSession::filter(
AuthSession::fields()
.token_hash()
.eq("live-mine".to_string())
)
.first()
.exec(&mut check)
.await
.expect("query")
.is_some(),
"a live session of the same user survives the purge"
);
let mut check = db.clone();
assert!(
AuthSession::filter(
AuthSession::fields()
.token_hash()
.eq("expired-other".to_string())
)
.first()
.exec(&mut check)
.await
.expect("query")
.is_none(),
"the sweep does not wait for the expired row's owner to sign in"
);
}
#[tokio::test]
async fn login_sweeps_at_most_a_batch() {
let mut db = db_with_admin("ada@example.com").await;
let ada = AdminUser::filter(
AdminUser::fields()
.email()
.eq("ada@example.com".to_string()),
)
.first()
.exec(&mut db)
.await
.expect("look up ada")
.expect("ada exists");
let expired = "2000-01-01T00:00:00Z"
.parse::<Timestamp>()
.expect("a past timestamp");
let mut seed = AuthSession::create_many();
for index in 0..=SESSION_SWEEP_BATCH {
seed = seed.item(
AuthSession::create()
.token_hash(format!("expired-{index}"))
.user_id(ada.id.to_string())
.panel("/admin")
.expires_at(expired)
.created_at(Timestamp::now()),
);
}
seed.exec(&mut db).await.expect("seed the expired rows");
let router = auth_router(db.clone());
let token = Uuid::new_v4().to_string();
let resp = router
.handle(login_request(
format!("email=ada@example.com&password=opensesame&csrf_token={token}"),
&token,
))
.await;
assert_eq!(
resp.status(),
http::StatusCode::SEE_OTHER,
"the login must succeed"
);
let mut check = db.clone();
let remaining = AuthSession::all()
.exec(&mut check)
.await
.expect("query")
.len();
assert_eq!(
remaining, 2,
"one expired row past the batch, plus the session this login created"
);
}
#[tokio::test]
async fn a_sweep_failure_maps_to_the_opaque_sign_in_copy() {
let cx = login_cx(schema_less_db().await, "token");
let error = sweep_expired_sessions(&cx)
.await
.expect_err("a schema-less database fails the sweep");
let rendered = error.to_string();
assert!(
rendered.contains(UNAVAILABLE_ERROR),
"the opaque message must survive, got {rendered}"
);
assert!(
!rendered.contains("no such table"),
"driver text must not leak, got {rendered}"
);
}
#[tokio::test]
async fn a_driver_session_delete_failure_does_not_echo_driver_text() {
use topcoat::context::CxTestBuilder;
let db = schema_less_db().await;
let mut raw = db.clone();
let driver = AuthSession::filter(AuthSession::fields().user_id().eq("ada".to_string()))
.delete()
.exec(&mut raw)
.await
.expect_err("the table is missing")
.to_string();
drop(raw);
assert!(
driver.contains("no such table"),
"the control must be a driver failure, got {driver:?}"
);
let cx = CxTestBuilder::new().app_context(db).build();
let error = revoke_sessions_for_user(&cx, "ada")
.await
.expect_err("the delete must fail");
let rendered = error.to_string();
assert!(
rendered.contains(UNAVAILABLE_ERROR),
"the opaque message must survive, got {rendered:?}"
);
assert!(
!rendered.contains(&driver) && !rendered.contains("no such table"),
"driver text must not reach the response: the driver said {driver:?}, the response said {rendered:?}"
);
}
#[topcoat::router::route(PUT "/admin/login")]
async fn app_put_at_the_login_path() -> topcoat::Result<&'static str> {
Ok("app route ran")
}
#[tokio::test]
async fn the_login_bypass_is_scoped_to_the_login_methods() {
let db = db_with_admin("ada@example.com").await;
let router = auth_router(db);
let put = router
.handle(
http::Request::builder()
.method(http::Method::PUT)
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(
put.status(),
http::StatusCode::UNAUTHORIZED,
"a logged-out PUT at the login path must stop at the gate"
);
let get = router
.handle(
http::Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(
get.status(),
http::StatusCode::OK,
"the login page must still answer while logged out"
);
}
struct Visitor;
impl PanelUser for Visitor {
fn user_id(&self) -> String {
"visitor".to_string()
}
fn display_name(&self) -> &str {
"Visitor"
}
}
fn ada() -> AdminUser {
AdminUser {
id: Uuid::nil(),
email: "ada@example.com".to_string(),
password_hash: String::new(),
display_name: "Ada".to_string(),
active: true,
created_at: Timestamp::UNIX_EPOCH,
}
}
fn signed_cx(user: impl PanelUser, panel: &Arc<PanelState>, serving: &Arc<PanelState>) -> Cx {
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts();
topcoat::context::CxTestBuilder::new()
.request_context(parts)
.request_context(crate::panel::state::CurrentPanel(Arc::clone(serving)))
.request_context(SignedIn {
user: Arc::new(user),
panel: Arc::clone(panel),
tenant: None,
})
.build()
}
#[test]
fn the_user_reads_back_as_the_apps_own_type_only() {
let panel = Arc::new(crate::panel::test_support::panel_state(
"/admin",
Auth::password(),
));
let cx = signed_cx(ada(), &panel, &panel);
assert_eq!(
user::<AdminUser>(&cx).map(|u| u.email.as_str()),
Some("ada@example.com")
);
assert!(signed_in(&cx));
assert!(
user::<Visitor>(&cx).is_none(),
"another type reads as nobody"
);
let refused = require_user::<Visitor>(&cx)
.err()
.expect("wrong type refused");
assert_eq!(refused.into_response(&cx).unwrap().status(), 403);
}
#[test]
fn a_user_is_nobody_on_another_panel() {
let admin = Arc::new(crate::panel::test_support::panel_state(
"/admin",
Auth::password(),
));
let staff = Arc::new(crate::panel::test_support::panel_state(
"/staff",
Auth::password(),
));
let cx = signed_cx(ada(), &admin, &staff);
assert!(user::<AdminUser>(&cx).is_none());
assert!(!signed_in(&cx));
}
#[test]
fn an_inactive_user_is_not_signed_in() {
let panel = Arc::new(crate::panel::test_support::panel_state(
"/admin",
Auth::password(),
));
let cx = signed_cx(
AdminUser {
active: false,
..ada()
},
&panel,
&panel,
);
assert!(user::<AdminUser>(&cx).is_none());
assert!(
resolved(&cx).is_some(),
"the logout route still reads the user"
);
}
struct Member(Vec<Membership>);
impl PanelUser for Member {
fn user_id(&self) -> String {
"member".to_string()
}
fn display_name(&self) -> &str {
"Member"
}
fn tenants(&self) -> &[Membership] {
&self.0
}
}
fn member_cx(
tenants: &[uuid::Uuid],
selected: Option<uuid::Uuid>,
overridden: Option<uuid::Uuid>,
) -> Cx {
let panel = std::sync::Arc::new(crate::panel::test_support::panel_state(
"/admin",
crate::Auth::password(),
));
let memberships = tenants
.iter()
.enumerate()
.map(|(index, tenant)| Membership::new(*tenant, format!("Tenant {index}")))
.collect();
let mut builder = topcoat::context::CxTestBuilder::new()
.request_context(crate::panel::state::CurrentPanel(std::sync::Arc::clone(
&panel,
)))
.request_context(SignedIn {
user: std::sync::Arc::new(Member(memberships)),
panel,
tenant: selected,
})
.app_context(crate::tenancy::TenantSource(session_tenant));
if let Some(tenant) = overridden {
builder = builder.request_context(crate::Tenant(tenant));
}
builder.build()
}
#[test]
fn the_request_acts_for_the_selected_membership_else_the_first() {
let (a, b, stranger) = (
uuid::Uuid::from_u128(1),
uuid::Uuid::from_u128(2),
uuid::Uuid::from_u128(3),
);
assert_eq!(crate::tenant_id(&member_cx(&[a, b], None, None)), Some(a));
assert_eq!(
crate::tenant_id(&member_cx(&[a, b], Some(b), None)),
Some(b)
);
let cx = member_cx(&[a, b], Some(b), None);
assert_eq!(membership(&cx).map(|m| m.name.as_str()), Some("Tenant 1"));
assert_eq!(
crate::tenant_id(&member_cx(&[a, b], Some(stranger), None)),
Some(a)
);
assert_eq!(crate::tenant_id(&member_cx(&[], Some(a), None)), None);
let cx = member_cx(&[a, b], None, Some(stranger));
assert_eq!(crate::tenant_id(&cx), Some(stranger));
assert_eq!(membership(&cx), None);
let cx = member_cx(&[a, b], None, Some(b));
assert_eq!(membership(&cx).map(|m| m.tenant), Some(b));
}