//! Repositories and SSH keys as the webui serves them.
//!
//! The SSH tests prove the whole path works end to end; these pin the web
//! surface on its own - what a page shows, what a stranger is told, and which
//! refusals come back as redirects rather than errors.

mod support;

use frogue::entities::repositories;
use miette::IntoDiagnostic;
use sea_orm::{Database, EntityTrait, PaginatorTrait};
use support::{Client, Server, free_port};

/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";

/// A real ed25519 public key, so the fingerprint the server computes can be
/// compared with what `ssh-keygen` says. Regenerated never: it is only ever a
/// key.
const PUBLIC_KEY: &str =
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAkLWlr2CY+UXEqurEAn4eb5OdKx2psKG8i033Wgpc4v test key";

/// The fingerprint `ssh-keygen -lf` reports for [`PUBLIC_KEY`].
const FINGERPRINT: &str = "SHA256:39WEiHo7Z2Zstu8Pf7Vhcbo6OBZYw+giz14QYIbZ/hE";

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)
}

/// Registers `name` through the form, which logs them in.
fn register(client: &mut Client, name: &str) {
    let email = format!("{name}@example.org");
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", &email), ("password", PASSWORD)],
    );
    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Creates a repository through the form, and checks it landed where it should.
fn create(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the webui tests"),
            ("visibility", visibility),
        ],
    );

    assert_eq!(
        response.status, 303,
        "creating `{owner}/{slug}` should redirect\n{}",
        response.body
    );
    assert_eq!(
        response.location.as_deref(),
        Some(format!("/~{owner}/{slug}").as_str()),
    );
}

#[tokio::test]
async fn creating_a_repository_lists_it_on_the_profile() -> miette::Result<()> {
    let (server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");

    create(&mut alice, "alice", "test-repo", "public");

    let response = alice.get("/~alice");
    assert_eq!(response.status, 200, "profile");
    assert!(
        response.body.contains("/~alice/test-repo"),
        "the profile should link to the new repository, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("public"),
        "the profile should say the repository is public, got:\n{}",
        response.body
    );

    // And the repository's own page shows how to clone it.
    let response = alice.get("/~alice/test-repo");
    assert_eq!(response.status, 200, "repository page");
    assert!(
        response.body.contains("ssh://git@"),
        "the repository page should show a clone URL, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn a_private_repository_is_hidden_from_strangers() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "secret", "private");

    // The owner sees it.
    assert_eq!(
        alice.get("/~alice/secret").status,
        200,
        "the owner should reach her private repository"
    );

    // Bob does not get to know it exists.
    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    assert_eq!(
        bob.get("/~alice/secret").status,
        404,
        "a stranger should not be told the private repository exists"
    );
    assert!(
        !bob.get("/~alice").body.contains("secret"),
        "the private repository should not be listed to a stranger"
    );

    // Nor does anyone signed out.
    assert_eq!(
        Client::new(port).get("/~alice/secret").status,
        404,
        "an anonymous visitor should not reach a private repository"
    );

    Ok(())
}

#[tokio::test]
async fn a_public_repository_is_readable_by_anyone() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");

    assert_eq!(
        Client::new(port).get("/~alice/test-repo").status,
        200,
        "a public repository should be readable while signed out"
    );
    assert!(
        Client::new(port).get("/~alice").body.contains("test-repo"),
        "a public repository should be listed to anyone"
    );

    Ok(())
}

#[tokio::test]
async fn creating_a_repository_is_the_accounts_own_affair() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");

    // Nobody signed in has no account to create under.
    let mut anonymous = Client::new(port);
    let response = anonymous.post_form(
        "/~alice/repositories",
        &[("slug", "test-repo"), ("visibility", "public")],
    );
    assert_eq!(
        response.status, 401,
        "creating a repository needs a session\n{}",
        response.body
    );

    // And a signed-in visitor cannot create one under someone else's name.
    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    let response = bob.post_form(
        "/~alice/repositories",
        &[("slug", "test-repo"), ("visibility", "public")],
    );
    assert_eq!(
        response.status, 403,
        "a visitor should not create repositories for someone else\n{}",
        response.body
    );

    // Nothing was created either way.
    assert_eq!(alice.get("/~alice/test-repo").status, 404);
    assert_eq!(bob.get("/~alice").status, 200);
    assert!(!bob.get("/~alice").body.contains("test-repo"));

    Ok(())
}

#[tokio::test]
async fn an_unusable_slug_is_refused_and_not_stored() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");

    for slug in ["has space", ".hidden", "notes.git", ""] {
        let response = alice.post_form(
            "/~alice/repositories",
            &[("slug", slug), ("visibility", "public")],
        );

        assert_eq!(
            response.status, 200,
            "{slug:?} should come back as the form, not a redirect\n{}",
            response.body
        );
        assert!(
            response.body.contains("cannot name a repository"),
            "{slug:?} should be refused with a reason, got:\n{}",
            response.body
        );
    }

    Ok(())
}

#[tokio::test]
async fn a_slug_taken_within_one_account_is_refused() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");

    let response = alice.post_form(
        "/~alice/repositories",
        &[("slug", "test-repo"), ("visibility", "public")],
    );
    assert_eq!(
        response.status, 200,
        "a duplicate should be refused\n{}",
        response.body
    );
    assert!(
        response.body.contains("already have a repository"),
        "the refusal should say the name is taken, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn two_accounts_may_have_a_repository_of_the_same_name() -> miette::Result<()> {
    let (_server, port) = spawn();

    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");

    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    create(&mut bob, "bob", "test-repo", "public");

    assert_eq!(alice.get("/~alice/test-repo").status, 200);
    assert_eq!(alice.get("/~bob/test-repo").status, 200);

    Ok(())
}

#[tokio::test]
async fn adding_and_removing_an_ssh_key() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");

    let response = alice.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)]);
    assert_eq!(
        response.status, 303,
        "adding a key should redirect\n{}",
        response.body
    );
    assert_eq!(response.location.as_deref(), Some("/~alice/ssh-keys"));

    // The list shows the key by the fingerprint `ssh-keygen` would report, and
    // by the comment its owner gave it.
    let response = alice.get("/~alice/ssh-keys");
    assert_eq!(response.status, 200, "key list\n{}", response.body);
    assert!(
        response.body.contains(FINGERPRINT),
        "the list should show the key's fingerprint, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("test key"),
        "the list should show the key's name, got:\n{}",
        response.body
    );

    // Deleting it takes it off the list. The id is 1: the first key stored.
    let response = alice.post_form("/~alice/ssh-keys/1/delete", &[]);
    assert_eq!(
        response.status, 303,
        "deleting a key should redirect\n{}",
        response.body
    );

    let response = alice.get("/~alice/ssh-keys");
    assert!(
        !response.body.contains(FINGERPRINT),
        "the deleted key should be gone, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn a_key_that_is_not_a_key_is_refused() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");

    let response = alice.post_form("/~alice/ssh-keys", &[("key", "this is not a key")]);
    assert_eq!(
        response.status, 200,
        "an unusable key should come back as the form\n{}",
        response.body
    );
    assert!(
        response.body.contains("not a public key this understands"),
        "the refusal should say why, got:\n{}",
        response.body
    );

    let response = alice.get("/~alice/ssh-keys");
    assert!(
        response.body.contains("No keys yet."),
        "nothing should have been stored, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn a_key_belonging_to_another_account_is_refused() -> miette::Result<()> {
    let (_server, port) = spawn();

    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    alice.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)]);

    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    let response = bob.post_form("/~bob/ssh-keys", &[("key", PUBLIC_KEY)]);

    assert_eq!(
        response.status, 200,
        "the same key should not be added twice\n{}",
        response.body
    );
    assert!(
        response.body.contains("another account"),
        "the refusal should say the key is taken, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn ssh_keys_are_the_accounts_own_affair() -> miette::Result<()> {
    let (_server, port) = spawn();

    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    alice.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)]);

    let mut bob = Client::new(port);
    register(&mut bob, "bob");

    // Bob cannot look at, add to, or delete from Alice's keys.
    assert_eq!(bob.get("/~alice/ssh-keys").status, 403);
    assert_eq!(
        bob.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)])
            .status,
        403
    );
    assert_eq!(bob.post_form("/~alice/ssh-keys/1/delete", &[]).status, 403);

    // Alice's key is still there.
    assert!(alice.get("/~alice/ssh-keys").body.contains(FINGERPRINT));

    // A visitor signed out is turned away, too.
    let mut anonymous = Client::new(port);
    assert_eq!(anonymous.get("/~alice/ssh-keys").status, 401);

    Ok(())
}

#[tokio::test]
async fn a_name_the_webui_uses_cannot_name_a_repository() -> miette::Result<()> {
    let (server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");

    // Each of these is a page of the webui under `/~alice/`. A repository that
    // took one of them would exist on disk but its page would show something
    // else, so it is refused up front.
    for slug in ["edit", "repositories", "ssh-keys"] {
        let response = alice.post_form(
            "/~alice/repositories",
            &[("slug", slug), ("visibility", "public")],
        );

        assert_eq!(
            response.status, 200,
            "{slug:?} should come back as the form, not a redirect\n{}",
            response.body
        );
        assert!(
            response.body.contains("reserved"),
            "{slug:?} should be refused as reserved, got:\n{}",
            response.body
        );
    }

    // And none of them was stored, on disk or in the database.
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;
    let stored = repositories::Entity::find()
        .count(&db)
        .await
        .into_diagnostic()?;
    assert_eq!(stored, 0, "no reserved name should have been stored");

    for slug in ["edit", "repositories", "ssh-keys"] {
        assert!(
            !server
                .repo_root()
                .join("alice")
                .join(format!("{slug}.git"))
                .exists(),
            "{slug:?} should not have been created on disk"
        );
    }

    // The pages those names belong to are still the webui's own.
    assert_eq!(
        alice.get("/~alice/edit").status,
        200,
        "the profile edit form"
    );
    assert_eq!(
        alice.get("/~alice/ssh-keys").status,
        200,
        "the SSH key list"
    );

    Ok(())
}
