mod support;
use axum::http::StatusCode;
use serde_json::json;
use support::TestServer;
const PASSWORD: &str = "correct horse battery staple";
#[tokio::test]
async fn signing_in_returns_a_cookie_that_opens_the_door() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (status, cookie, body) = server.login("boss@example.test", PASSWORD).await;
assert_eq!(status, StatusCode::OK, "{body}");
let cookie = cookie.expect("signing in must set a cookie");
assert!(cookie.contains("HttpOnly"), "{cookie}");
assert!(cookie.contains("SameSite=Strict"), "{cookie}");
let (status, body) = server.get_with_cookie("/api/v1/auth/me", Some(&cookie)).await;
assert_eq!(status, StatusCode::OK, "the cookie should identify the caller: {body}");
assert_eq!(body["email"], "boss@example.test");
assert_eq!(body["role"], "admin");
}
#[tokio::test]
async fn a_wrong_password_and_an_unknown_email_are_the_same_answer() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (wrong_status, _, wrong_body) = server.login("boss@example.test", "not the password").await;
let (unknown_status, _, unknown_body) = server.login("nobody@example.test", PASSWORD).await;
assert_eq!(wrong_status, StatusCode::UNAUTHORIZED);
assert_eq!(unknown_status, StatusCode::UNAUTHORIZED);
assert_eq!(wrong_body["error"], unknown_body["error"], "the two refusals must be indistinguishable");
}
#[tokio::test]
async fn an_account_without_a_password_cannot_be_signed_into() {
let Some(server) = TestServer::start().await else { return };
let (status, _, _) = server.login("employee@example.test", "").await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, _) = server.login("employee@example.test", PASSWORD).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn a_protected_route_refuses_a_caller_without_a_session() {
let Some(server) = TestServer::start().await else { return };
for (label, cookie) in [
("no cookie", None),
("a made-up token", Some("kasl_session=deadbeef".to_string())),
("someone else's cookie name", Some("other_session=deadbeef".to_string())),
] {
let (status, _) = server.get_with_cookie("/api/v1/auth/me", cookie.as_deref()).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{label} must not be let in");
}
}
#[tokio::test]
async fn logging_out_ends_the_session_immediately() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (_, cookie, _) = server.login("boss@example.test", PASSWORD).await;
let cookie = cookie.unwrap();
let (status, cleared, _) = server.post_with_cookie("/api/v1/auth/logout", Some(&cookie), json!({})).await;
assert_eq!(status, StatusCode::OK);
assert!(cleared.expect("logout should clear the cookie").contains("Max-Age=0"));
let (status, _) = server.get_with_cookie("/api/v1/auth/me", Some(&cookie)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "the token must stop working, not just be dropped");
assert_eq!(server.count("sessions").await, 0);
}
#[tokio::test]
async fn logging_out_everywhere_ends_the_other_sessions_too() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (_, laptop, _) = server.login("boss@example.test", PASSWORD).await;
let (_, desktop, _) = server.login("boss@example.test", PASSWORD).await;
let (laptop, desktop) = (laptop.unwrap(), desktop.unwrap());
assert_eq!(server.count("sessions").await, 2);
let (status, _, body) = server.post_with_cookie("/api/v1/auth/logout-everywhere", Some(&desktop), json!({})).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["ended"], 2, "both sessions, including the one asking");
for (label, cookie) in [("the laptop", &laptop), ("the desktop", &desktop)] {
let (status, _) = server.get_with_cookie("/api/v1/auth/me", Some(cookie)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{label} must be signed out");
}
assert_eq!(server.count("sessions").await, 0);
}
#[tokio::test]
async fn deactivating_an_employee_closes_their_session() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (_, cookie, _) = server.login("boss@example.test", PASSWORD).await;
let cookie = cookie.unwrap();
server.execute("UPDATE users SET active = false WHERE email = 'boss@example.test'").await;
let (status, _) = server.get_with_cookie("/api/v1/auth/me", Some(&cookie)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "a deactivated account must not keep working from an open tab");
}
#[tokio::test]
async fn an_expired_session_is_refused_even_before_it_is_swept() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (_, cookie, _) = server.login("boss@example.test", PASSWORD).await;
let cookie = cookie.unwrap();
server.execute("UPDATE sessions SET expires_at = now() - interval '1 hour'").await;
let (status, _) = server.get_with_cookie("/api/v1/auth/me", Some(&cookie)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(server.count("sessions").await, 1, "and the row is still there, refused rather than missing");
let swept = kasl_server::session::sweep_expired(&server.pool).await.unwrap();
assert_eq!(swept, 1, "the sweep is what actually removes it");
assert_eq!(server.count("sessions").await, 0);
}
#[tokio::test]
async fn a_session_in_use_keeps_itself_alive() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (_, cookie, _) = server.login("boss@example.test", PASSWORD).await;
let cookie = cookie.unwrap();
server.execute("UPDATE sessions SET expires_at = now() + interval '1 hour'").await;
let (status, _) = server.get_with_cookie("/api/v1/auth/me", Some(&cookie)).await;
assert_eq!(status, StatusCode::OK);
let days: f64 = server
.scalar("SELECT (extract(epoch FROM (expires_at - now())) / 86400)::float8 FROM sessions")
.await;
assert!(days > 13.0, "using the session should push its expiry out again, got {days} days");
}
#[tokio::test]
async fn an_employee_is_not_an_admin() {
let Some(server) = TestServer::start().await else { return };
server.set_password("employee@example.test", PASSWORD).await;
let (status, cookie, _) = server.login("employee@example.test", PASSWORD).await;
assert_eq!(status, StatusCode::OK);
let (status, body) = server.get_with_cookie("/api/v1/auth/me", cookie.as_deref()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["role"], "employee", "signing in must not confer a role nobody granted");
}
#[tokio::test]
async fn an_agent_token_is_not_a_session_and_a_session_is_not_an_agent_token() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", PASSWORD).await;
let (_, cookie, _) = server.login("boss@example.test", PASSWORD).await;
let day = json!({"date": "2026-08-18", "started_at": "2026-08-18T09:00:00-03:00"});
let (status, _) = server.post_day_with_cookie(cookie.as_deref(), day.clone()).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "the ingest routes take a bearer token, not a cookie");
let (status, _) = server.get_with_header("/api/v1/auth/me", Some(&format!("Bearer {}", server.token))).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "an agent has no session and no reason to have one");
}
#[tokio::test]
async fn the_admin_bootstrap_creates_and_then_resets() {
let Some(server) = TestServer::start().await else { return };
kasl_server::provision::ensure_admin(&server.pool, "boss@example.test", PASSWORD)
.await
.expect("the first admin should be created");
let (status, _, _) = server.login("boss@example.test", PASSWORD).await;
assert_eq!(status, StatusCode::OK);
kasl_server::provision::ensure_admin(&server.pool, "boss@example.test", "an entirely different one")
.await
.expect("a second run should reset the password");
let (status, _, _) = server.login("boss@example.test", PASSWORD).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "the old password must be gone");
let (status, _, _) = server.login("boss@example.test", "an entirely different one").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(server.count("users").await, 2, "and no second account for the same person");
}
#[tokio::test]
async fn promoting_an_existing_employee_to_admin_keeps_their_history() {
let Some(server) = TestServer::start().await else { return };
let before: uuid::Uuid = server.scalar("SELECT id FROM users WHERE email = 'employee@example.test'").await;
kasl_server::provision::ensure_admin(&server.pool, "employee@example.test", PASSWORD)
.await
.expect("an existing user should be promoted");
let after: uuid::Uuid = server.scalar("SELECT id FROM users WHERE email = 'employee@example.test'").await;
assert_eq!(before, after, "the account must be the same row, or its days would be orphaned");
assert_eq!(server.count("users").await, 1, "not a second account");
let (status, cookie, _) = server.login("employee@example.test", PASSWORD).await;
assert_eq!(status, StatusCode::OK);
let (_, body) = server.get_with_cookie("/api/v1/auth/me", cookie.as_deref()).await;
assert_eq!(body["role"], "admin");
}
#[test]
fn the_bootstrap_refuses_a_password_too_short_to_be_one() {
let raw = kasl_server::provision::parse_admin("boss@example.com:secret").expect("a well-formed value parses");
assert_eq!(raw, Some(("boss@example.com".to_string(), "secret".to_string())));
let raw = kasl_server::provision::parse_admin("boss@example.com:a:b:c").unwrap();
assert_eq!(raw.unwrap().1, "a:b:c");
assert!(kasl_server::provision::parse_admin("").unwrap().is_none(), "an absent value is not an error");
assert!(kasl_server::provision::parse_admin("boss@example.com").is_err(), "no password at all is");
assert!(kasl_server::provision::parse_admin("boss@example.com:").is_err(), "nor an empty one");
}