//! Registration, login and logout, and what being logged in lets you do.
//!
//! These tests drive the real binary over HTTP the way a browser would: they
//! post forms, keep the cookies the server sets, and then check that the
//! session that cookie stands for is honoured on the next request.
mod support;
use frogue::entities::users;
use miette::IntoDiagnostic;
use sea_orm::{ActiveModelTrait, Database, DatabaseConnection, EntityTrait, Set};
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
fn spawn() -> (Server, u16) {
let port = free_port();
let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
(server, port)
}
async fn connect(server: &Server) -> DatabaseConnection {
Database::connect(server.database_url())
.await
.expect("the server's database should be reachable")
}
/// Registers through the form, the way the browser would.
fn register(client: &mut Client, name: &str, email: &str, password: &str) -> support::Response {
client.post_form(
"/register",
&[("name", name), ("email", email), ("password", password)],
)
}
#[tokio::test]
async fn registering_an_account_logs_it_in() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
let response = register(&mut client, "alice", "alice@example.org", PASSWORD);
assert_eq!(response.status, 303, "registering should end in a redirect");
assert!(
response.sets_cookie("frogue_session"),
"registering should start a session\n{:?}",
response.set_cookies()
);
Ok(())
}
#[tokio::test]
async fn registering_stores_a_password_hash_not_the_password() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
register(&mut client, "alice", "alice@example.org", PASSWORD);
let db = connect(&server).await;
let user = users::Entity::find()
.one(&db)
.await
.into_diagnostic()?
.expect("the registered user should be in the database");
assert_ne!(
user.password_hash, PASSWORD,
"the password must not be stored as typed"
);
assert!(
user.password_hash.starts_with("$argon2"),
"the stored value should be an argon2 hash, got {:?}",
user.password_hash
);
assert!(
user.password_hash.len() > 32,
"the hash must be stored whole, not truncated to the column's old width, got {} characters: {:?}",
user.password_hash.len(),
user.password_hash
);
Ok(())
}
#[tokio::test]
async fn registering_a_taken_name_is_refused() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
register(&mut client, "alice", "alice@example.org", PASSWORD);
let response = register(&mut client, "alice", "other@example.org", PASSWORD);
assert_eq!(response.status, 409, "a taken name should be refused");
Ok(())
}
#[tokio::test]
async fn registering_input_that_cannot_be_used_is_refused() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
// The name becomes a path segment (`/~name`), so anything that would not
// survive being one has to be refused rather than stored.
let cases = [
("a b", "alice@example.org", PASSWORD, "a name with a space"),
("a/b", "alice@example.org", PASSWORD, "a name with a slash"),
(
"alice",
"not-an-email",
PASSWORD,
"an address with no at sign",
),
(
"alice",
"alice@example.org",
"short",
"a password that is too short",
),
];
for (name, email, password, what) in cases {
let response = register(&mut client, name, email, password);
assert_eq!(response.status, 400, "{what} should be refused");
assert!(
!response.sets_cookie("frogue_session"),
"{what} must not start a session"
);
}
Ok(())
}
#[tokio::test]
async fn logging_in_with_the_right_password_works() -> miette::Result<()> {
let (server, port) = spawn();
let db = connect(&server).await;
create_user(&db, "alice", "alice@example.org", PASSWORD).await;
let mut client = Client::new(port);
let response = client.post_form("/login", &[("name", "alice"), ("password", PASSWORD)]);
assert_eq!(
response.status, 303,
"correct credentials should be accepted"
);
assert!(
response.sets_cookie("frogue_session"),
"logging in should start a session"
);
Ok(())
}
#[tokio::test]
async fn logging_in_with_the_wrong_password_is_refused() -> miette::Result<()> {
let (server, port) = spawn();
let db = connect(&server).await;
create_user(&db, "alice", "alice@example.org", PASSWORD).await;
let mut client = Client::new(port);
let response = client.post_form(
"/login",
&[("name", "alice"), ("password", "not the password")],
);
assert_eq!(response.status, 401, "the wrong password should be refused");
assert!(
!response.sets_cookie("frogue_session"),
"a refused login must not start a session"
);
Ok(())
}
#[tokio::test]
async fn logging_in_as_nobody_is_refused() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
let response = client.post_form("/login", &[("name", "nobody"), ("password", PASSWORD)]);
assert_eq!(response.status, 401, "an unknown user should be refused");
Ok(())
}
#[tokio::test]
async fn logging_out_ends_the_session() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
register(&mut client, "alice", "alice@example.org", PASSWORD);
// The session works before logging out: the edit form is the one page
// that needs it.
let before = client.get("/~alice/edit");
assert_eq!(
before.status, 200,
"a fresh session should reach its own edit form"
);
let logout = client.post_form("/logout", &[]);
assert_eq!(logout.status, 303, "logging out should end in a redirect");
let after = client.get("/~alice/edit");
assert_ne!(
after.status, 200,
"a logged out client should not reach the edit form"
);
Ok(())
}
#[tokio::test]
async fn the_navigation_offers_a_way_in_and_a_way_out() -> miette::Result<()> {
let (server, port) = spawn();
let db = connect(&server).await;
create_user(&db, "alice", "alice@example.org", PASSWORD).await;
let mut client = Client::new(port);
let anonymous = client.get("/");
assert!(
anonymous.body.contains("href=\"/login\"") && anonymous.body.contains("href=\"/register\""),
"an anonymous page should offer the ways in, got: {}",
anonymous.body
);
assert!(
!anonymous.body.contains("action=\"/logout\""),
"an anonymous page must not offer logging out, got: {}",
anonymous.body
);
client.post_form("/login", &[("name", "alice"), ("password", PASSWORD)]);
let signed_in = client.get("/");
assert!(
signed_in.body.contains("action=\"/logout\""),
"a signed-in page should offer logging out"
);
assert!(
signed_in.body.contains("href=\"/~alice\""),
"a signed-in page should link back to your own profile, got: {}",
signed_in.body
);
Ok(())
}
#[tokio::test]
async fn the_edit_form_needs_a_session() -> miette::Result<()> {
let (server, port) = spawn();
let db = connect(&server).await;
create_user(&db, "alice", "alice@example.org", PASSWORD).await;
let mut client = Client::new(port);
let response = client.get("/~alice/edit");
assert_eq!(
response.status, 401,
"an anonymous client should not reach the edit form"
);
Ok(())
}
#[tokio::test]
async fn the_edit_form_refuses_someone_elses_profile() -> miette::Result<()> {
let (server, port) = spawn();
let db = connect(&server).await;
create_user(&db, "alice", "alice@example.org", PASSWORD).await;
// Mallory registers and gets a session of her own, but it is not alice's.
let mut client = Client::new(port);
register(&mut client, "mallory", "mallory@example.org", PASSWORD);
let response = client.get("/~alice/edit");
assert_eq!(
response.status, 403,
"a session must not edit another user's profile"
);
Ok(())
}
#[tokio::test]
async fn editing_a_profile_updates_it() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
register(&mut client, "alice", "alice@example.org", PASSWORD);
let response = client.post_form(
"/~alice/edit",
&[("description", "Hello, I write software.")],
);
assert_eq!(response.status, 303, "editing should end in a redirect");
let profile = client.get("/~alice");
assert!(
profile.body.contains("Hello, I write software."),
"the new description should show on the profile, got: {}",
profile.body
);
Ok(())
}
/// Creates a user the way `register` would, for tests whose subject is
/// logging in rather than signing up.
async fn create_user(db: &DatabaseConnection, name: &str, email: &str, password: &str) {
let user = users::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
name: Set(name.to_string()),
email: Set(email.to_string()),
password_hash: Set(
frogue::auth::hash_password(password).expect("the password should be hashable")
),
..Default::default()
};
user.insert(db)
.await
.expect("test user should be insertable");
}