//! 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(¬e_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(¬e_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(())
}