🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! ActivityPub actor and webfinger integration tests.
//!
//! These tests verify the Phase 1 deliverables: actor JSON documents, webfinger
//! discovery, lazy keypair generation, and the federation gate (disabled → 404).

mod support;

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

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

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

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

/// Test 1: Actor JSON has the right fields and structure.
#[tokio::test]
async fn actor_json_has_correct_fields() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&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 mut client = Client::new(port);
    let response = client.get_with("/~alice", &[("Accept", "application/activity+json")]);

    assert_eq!(response.status, 200, "actor should be found");
    assert_eq!(
        response.content_type.as_deref(),
        Some("application/activity+json; charset=utf-8"),
        "content type should be activity+json"
    );

    let json: serde_json::Value =
        serde_json::from_str(&response.body).expect("response should be valid JSON");

    let expected_id = format!("http://127.0.0.1:{port}/~alice");
    assert_eq!(json["id"], expected_id, "actor id should match");
    assert_eq!(json["type"], "Person", "type should be Person");
    assert_eq!(
        json["preferredUsername"], "alice",
        "preferredUsername should be alice"
    );
    assert!(json["inbox"].as_str().is_some(), "inbox should be present");
    assert!(
        json["outbox"].as_str().is_some(),
        "outbox should be present"
    );
    assert!(
        json["followers"].as_str().is_some(),
        "followers should be present"
    );

    let public_key = &json["publicKey"];
    assert!(public_key.is_object(), "publicKey should be an object");
    assert_eq!(
        public_key["id"],
        format!("{expected_id}#main-key"),
        "key id should match"
    );
    assert_eq!(
        public_key["owner"], expected_id,
        "key owner should match actor id"
    );

    let pem = public_key["publicKeyPem"]
        .as_str()
        .expect("publicKeyPem should be a string");
    assert!(
        pem.contains("-----BEGIN PUBLIC KEY-----"),
        "publicKeyPem should be PEM"
    );
    assert!(
        pem.contains("-----END PUBLIC KEY-----"),
        "publicKeyPem should be PEM"
    );

    Ok(())
}

/// Test 2: Same request without the Accept header → HTML profile (existing behaviour).
#[tokio::test]
async fn actor_without_accept_header_returns_html() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&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 mut client = Client::new(port);
    let response = client.get("/~alice");

    assert_eq!(response.status, 200, "profile should be found");
    assert_eq!(
        response.content_type.as_deref(),
        Some("text/html; charset=utf-8"),
        "content type should be HTML"
    );
    assert!(
        response.body.contains("<!DOCTYPE html>"),
        "response should be a complete HTML document, got: {}",
        response.body
    );

    Ok(())
}

/// Test 3: Two actor fetches → identical `publicKeyPem` (keypair persisted).
#[tokio::test]
async fn actor_keypair_is_persisted() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&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 mut client = Client::new(port);

    let response1 = client.get_with("/~alice", &[("Accept", "application/activity+json")]);
    let json1: serde_json::Value =
        serde_json::from_str(&response1.body).expect("first response should be valid JSON");

    let response2 = client.get_with("/~alice", &[("Accept", "application/activity+json")]);
    let json2: serde_json::Value =
        serde_json::from_str(&response2.body).expect("second response should be valid JSON");

    assert_eq!(
        json1["publicKey"]["publicKeyPem"], json2["publicKey"]["publicKeyPem"],
        "public key should be identical across requests (keypair persisted)"
    );

    Ok(())
}

/// Test 4: Webfinger returns correct JRD, validates host and user.
#[tokio::test]
async fn webfinger_returns_correct_jrd() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&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 mut client = Client::new(port);

    // Valid resource → 200 JRD.
    let resource = format!("acct:alice@127.0.0.1:{port}");
    let response = client.get(&format!("/.well-known/webfinger?resource={resource}"));
    assert_eq!(response.status, 200, "webfinger should succeed");
    assert_eq!(
        response.content_type.as_deref(),
        Some("application/jrd+json"),
        "content type should be jrd+json"
    );

    let jrd: serde_json::Value =
        serde_json::from_str(&response.body).expect("webfinger response should be valid JSON");
    assert_eq!(jrd["subject"], resource, "subject should match resource");

    let links = jrd["links"].as_array().expect("links should be an array");
    assert!(!links.is_empty(), "links should not be empty");

    let self_link = &links[0];
    assert_eq!(self_link["rel"], "self", "first link rel should be self");
    assert_eq!(
        self_link["type"], "application/activity+json",
        "first link type should be activity+json"
    );
    let expected_href = format!("http://127.0.0.1:{port}/~alice");
    assert_eq!(
        self_link["href"], expected_href,
        "first link href should be actor URL"
    );

    // Wrong host → 404.
    let wrong_host_response = client.get("/.well-known/webfinger?resource=acct:alice@wrong.host");
    assert_eq!(
        wrong_host_response.status, 404,
        "wrong host should return 404"
    );

    // Unknown user → 404.
    let unknown_user_response = client.get(&format!(
        "/.well-known/webfinger?resource=acct:unknown@127.0.0.1:{port}"
    ));
    assert_eq!(
        unknown_user_response.status, 404,
        "unknown user should return 404"
    );

    // Missing resource → 400.
    let missing_resource_response = client.get("/.well-known/webfinger");
    assert_eq!(
        missing_resource_response.status, 400,
        "missing resource should return 400"
    );

    Ok(())
}

/// Test 5: Unknown user actor → 404.
#[tokio::test]
async fn unknown_user_actor_returns_404() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = Client::new(port);
    let response = client.get_with("/~unknown", &[("Accept", "application/activity+json")]);

    assert_eq!(response.status, 404, "unknown user should return 404");

    Ok(())
}

/// Test 6: Non-federated spawn (no FROGUE_PUBLIC_URL) → actor and webfinger → 404.
#[tokio::test]
async fn non_federated_spawn_returns_404() -> 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 mut client = Client::new(port);

    // Actor request → 404.
    let actor_response = client.get_with("/~alice", &[("Accept", "application/activity+json")]);
    assert_eq!(
        actor_response.status, 404,
        "actor should return 404 when federation is disabled"
    );

    // Webfinger → 404.
    let webfinger_response =
        client.get("/.well-known/webfinger?resource=acct:alice@127.0.0.1:6565");
    assert_eq!(
        webfinger_response.status, 404,
        "webfinger should return 404 when federation is disabled"
    );

    Ok(())
}

/// Test 7: Alice posts a note with markdown, sees it rendered on profile.
#[tokio::test]
async fn user_can_post_note_with_markdown() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Post a note with markdown
    let response = client.post_form("/~alice/notes", &[("content", "hello **world**")]);

    assert_eq!(response.status, 303, "posting a note should redirect");

    // Check that the profile page contains the rendered markdown
    let profile_response = client.get("/~alice");
    assert_eq!(profile_response.status, 200, "profile should load");
    assert!(
        profile_response.body.contains("<strong>world</strong>"),
        "profile should contain rendered markdown, got: {}",
        profile_response.body
    );

    // Check that dangerous HTML is sanitized
    let response2 = client.post_form(
        "/~alice/notes",
        &[("content", "<script>alert('xss')</script>")],
    );
    assert_eq!(
        response2.status, 303,
        "posting another note should redirect"
    );

    let profile_response2 = client.get("/~alice");
    assert!(
        !profile_response2.body.contains("<script>"),
        "profile should not contain unescaped script tags, got: {}",
        profile_response2.body
    );

    Ok(())
}

/// Test 8: Only the profile owner can post notes.
#[tokio::test]
async fn only_owner_can_post_notes() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Log out alice
    client.post_form("/logout", &[]);

    // Register bob
    register(&mut client, "bob");

    // Try to post as bob to alice's profile
    let response = client.post_form("/~alice/notes", &[("content", "should fail")]);
    assert_eq!(
        response.status, 403,
        "bob should not be able to post to alice's profile"
    );

    // Try to post while anonymous
    let mut client2 = Client::new(port);
    let response2 = client2.post_form("/~alice/notes", &[("content", "should fail")]);
    assert_eq!(
        response2.status, 401,
        "anonymous user should not be able to post notes"
    );

    Ok(())
}

/// Test 9: Outbox contains posted notes as Create activities.
#[tokio::test]
async fn outbox_contains_notes() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Post a note
    client.post_form("/~alice/notes", &[("content", "hello **world**")]);

    // Fetch the outbox
    let response = client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);

    assert_eq!(response.status, 200, "outbox should load");

    let json: serde_json::Value =
        serde_json::from_str(&response.body).expect("outbox should be valid JSON");

    assert_eq!(
        json["totalItems"].as_u64(),
        Some(1),
        "outbox should have 1 item"
    );

    let first_url = json["first"]
        .as_str()
        .expect("outbox should have first page URL");
    assert!(
        first_url.contains("/~alice/outbox"),
        "first URL should be valid"
    );

    // Fetch the first page
    let page_response = client.get_with(first_url, &[("Accept", "application/activity+json")]);

    let page_json: serde_json::Value =
        serde_json::from_str(&page_response.body).expect("page should be valid JSON");

    let items = page_json["orderedItems"]
        .as_array()
        .expect("should have items");
    assert_eq!(items.len(), 1, "should have 1 item on first page");

    let create_activity = &items[0];
    assert_eq!(
        create_activity["type"], "Create",
        "activity should be a Create"
    );

    let note = &create_activity["object"];
    assert_eq!(note["type"], "Note", "object should be a Note");

    Ok(())
}

/// Test 10: Individual note endpoint returns Note document.
#[tokio::test]
async fn note_endpoint_returns_note() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Post a note
    client.post_form("/~alice/notes", &[("content", "hello **world**")]);

    // Get the outbox to find the note URL
    let outbox_response =
        client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
    let outbox_json: serde_json::Value =
        serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");

    let first_url = outbox_json["first"]
        .as_str()
        .expect("outbox should have first page");
    let page_response = client.get_with(first_url, &[("Accept", "application/activity+json")]);
    let page_json: serde_json::Value =
        serde_json::from_str(&page_response.body).expect("page should be valid JSON");

    let note_url = page_json["orderedItems"][0]["object"]["id"]
        .as_str()
        .expect("note should have an id");

    // Fetch the individual note
    let note_response = client.get_with(note_url, &[("Accept", "application/activity+json")]);

    assert_eq!(note_response.status, 200, "note should load");

    let note_json: serde_json::Value =
        serde_json::from_str(&note_response.body).expect("note should be valid JSON");

    assert_eq!(note_json["type"], "Note", "should be a Note");
    assert_eq!(
        note_json["attributedTo"],
        format!("http://127.0.0.1:{port}/~alice"),
        "attributedTo should be alice"
    );
    assert!(
        note_json["content"]
            .as_str()
            .unwrap()
            .contains("<strong>world</strong>"),
        "content should contain rendered markdown"
    );
    assert!(
        note_json["to"]
            .as_array()
            .unwrap()
            .iter()
            .any(|v| v.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")),
        "to should contain Public"
    );

    Ok(())
}

/// Test 11: XSS in notes is sanitized.
#[tokio::test]
async fn note_xss_is_sanitized() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Post a note with XSS attempt
    client.post_form(
        "/~alice/notes",
        &[("content", "<img src=x onerror=alert(1)>")],
    );

    // Check the profile page
    let profile_response = client.get("/~alice");
    assert!(
        !profile_response.body.contains("onerror="),
        "profile should not contain unescaped onerror, got: {}",
        profile_response.body
    );

    // Get the outbox to find the note URL
    let outbox_response =
        client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
    let outbox_json: serde_json::Value =
        serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");

    let first_url = outbox_json["first"]
        .as_str()
        .expect("outbox should have first page");
    let page_response = client.get_with(first_url, &[("Accept", "application/activity+json")]);
    let page_json: serde_json::Value =
        serde_json::from_str(&page_response.body).expect("page should be valid JSON");

    let note_url = page_json["orderedItems"][0]["object"]["id"]
        .as_str()
        .expect("note should have an id");

    // Fetch the individual note
    let note_response = client.get_with(note_url, &[("Accept", "application/activity+json")]);
    let note_json: serde_json::Value =
        serde_json::from_str(&note_response.body).expect("note should be valid JSON");

    let content = note_json["content"].as_str().unwrap();
    assert!(
        !content.contains("onerror="),
        "note content should not contain unescaped onerror, got: {}",
        content
    );

    Ok(())
}

/// Test 12: Local user can react and unreact to notes
#[tokio::test]
async fn local_user_can_react_to_notes() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Alice posts a note
    alice_client.post_form("/~alice/notes", &[("content", "test note")]);

    // Get the note URL
    let outbox_response =
        alice_client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
    let outbox_json: serde_json::Value =
        serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");
    let first_url = outbox_json["first"]
        .as_str()
        .expect("outbox should have first page");
    let page_response =
        alice_client.get_with(first_url, &[("Accept", "application/activity+json")]);
    let page_json: serde_json::Value =
        serde_json::from_str(&page_response.body).expect("page should be valid JSON");
    let note_url = page_json["orderedItems"][0]["object"]["id"]
        .as_str()
        .expect("note should have an id");

    // Extract note UUID from URL
    let note_uuid = note_url.rsplit('/').next().unwrap();

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

    // Bob reacts to alice's note
    let react_response = bob_client.post_form(&format!("/~alice/notes/{}/react", note_uuid), &[]);
    assert_eq!(
        react_response.status, 303,
        "react should redirect, got: {}",
        react_response.body
    );

    // Check that the reaction is shown
    let profile_response = bob_client.get("/~alice");
    assert!(
        profile_response.body.contains("note-reactions"),
        "profile should show reaction counts after bob's react, got: {}",
        profile_response.body
    );

    // Bob unreacts
    let unreact_response =
        bob_client.post_form(&format!("/~alice/notes/{}/unreact", note_uuid), &[]);
    assert_eq!(
        unreact_response.status, 303,
        "unreact should redirect, got: {}",
        unreact_response.body
    );

    // Check that the reaction is gone
    let profile_response = bob_client.get("/~alice");
    assert!(
        !profile_response.body.contains("note-reactions"),
        "profile should not show reaction counts after bob's unreact, got: {}",
        profile_response.body
    );

    // Anonymous user tries to react
    let mut anon_client = Client::new(port);
    let anon_react_response =
        anon_client.post_form(&format!("/~alice/notes/{}/react", note_uuid), &[]);
    assert_eq!(
        anon_react_response.status, 401,
        "anonymous user should not be able to react"
    );

    // Bob tries to react twice (should still be 1 like due to unique index)
    bob_client.post_form(&format!("/~alice/notes/{}/react", note_uuid), &[]);
    bob_client.post_form(&format!("/~alice/notes/{}/react", note_uuid), &[]);

    let profile_response = bob_client.get("/~alice");
    assert!(
        profile_response.body.contains("❤️"),
        "profile should show 1 like after bob's double react (unique index), got: {}",
        profile_response.body
    );

    Ok(())
}

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

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

    let response = client.get("/~alice");
    assert_eq!(response.status, 200);
    assert!(
        response
            .body
            .contains(r#"<link rel="alternate" type="application/activity+json""#),
        "profile should include alternate link, got: {}",
        response.body
    );
    assert!(
        response
            .body
            .contains(&format!(r#"href="http://127.0.0.1:{port}/~alice""#)),
        "alternate link should point to actor URL, got: {}",
        response.body
    );

    Ok(())
}