//! 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");
}
