🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The webui answers with complete HTML documents.
//!
//! These tests pin what actually goes over the wire: every page must be a
//! full document served as `text/html`, and whatever a user picked as their
//! name must be escaped rather than served as markup - the profile path
//! segment is attacker-controlled.

mod support;

use frogue::entities::users;
use miette::IntoDiagnostic;
use sea_orm::{ActiveModelTrait, Database, DatabaseConnection, Set};
use support::{Server, free_port, get};

/// Inserts a user through the entity the server itself uses, so the test
/// breaks loudly when the schema changes instead of inserting into a table
/// the server no longer reads.
async fn insert_user(db: &DatabaseConnection, name: &str, email: &str) {
    let user = users::ActiveModel {
        uuid: Set(uuid::Uuid::new_v4()),
        name: Set(name.to_string()),
        email: Set(email.to_string()),
        password_hash: Set("irrelevant".to_string()),
        ..Default::default()
    };
    user.insert(db)
        .await
        .expect("test user should be insertable");
}

#[tokio::test]
async fn index_renders_the_home_page() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let response = get(port, "/");

    assert_eq!(response.status, 200, "index");
    assert_eq!(
        response.content_type.as_deref(),
        Some("text/html; charset=utf-8"),
        "the index should be sent as HTML"
    );
    assert!(
        response.body.starts_with("<!DOCTYPE html>"),
        "the index should be a complete document, got: {}",
        response.body
    );
    assert!(
        response.body.contains("<title>Home - Frogue</title>"),
        "the index should carry its title, got: {}",
        response.body
    );
    assert!(
        response
            .body
            .contains("<h1><a href=\"/\">🐸 Frogue</a></h1>"),
        "the index should carry the site header, got: {}",
        response.body
    );
    assert!(
        response
            .body
            .contains("Powered by <a href=\"#no-public-url-yet\"><em>Frogue</em></a> version "),
        "the index should carry the site footer, got: {}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn profile_renders_the_user_name() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;
    insert_user(&db, "alice", "alice@example.org").await;

    let response = get(port, "/~alice");

    assert_eq!(response.status, 200, "profile");
    assert_eq!(
        response.content_type.as_deref(),
        Some("text/html; charset=utf-8"),
        "the profile should be sent as HTML"
    );
    assert!(
        response.body.contains("<title>~alice - Frogue</title>"),
        "the profile should carry its title, got: {}",
        response.body
    );
    assert!(
        response
            .body
            .contains("<h2><a class=\"frogue-username\" href=\"/~alice\">~alice</a></h2>"),
        "the profile should show the user name, got: {}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn profile_escapes_the_user_name() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let name = "<img src=x onerror=alert(1)>";
    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;
    insert_user(&db, name, "mallory@example.org").await;

    // Percent-encoded, so the hostile name stays one path segment on its way
    // to the route extractor.
    let response = get(port, "/~%3Cimg%20src=x%20onerror=alert(1)%3E");

    assert_eq!(response.status, 200, "profile");
    assert!(
        !response.body.contains("<img"),
        "a user name must not be served as markup, got: {}",
        response.body
    );
    assert!(
        response.body.contains("&lt;img src=x onerror=alert(1)&gt;"),
        "the escaped user name should still be readable, got: {}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn profile_of_an_unknown_user_is_not_found() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let response = get(port, "/~nobody");

    assert_eq!(response.status, 404, "profile");

    Ok(())
}

#[tokio::test]
async fn profile_is_only_served_under_the_tilde() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;
    insert_user(&db, "alice", "alice@example.org").await;

    let response = get(port, "/alice");

    assert_eq!(
        response.status, 404,
        "a profile should not be served without the tilde"
    );

    Ok(())
}