feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

+499 -1
View File
@@ -3,12 +3,43 @@
#[path = "parity/mod.rs"]
mod parity_support;
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
use axum::{
Json, Router,
extract::{Path, RawQuery},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use fluxer_admin::api::{
generated::types::{
AdminReportListResponse, LookupGuildResponse, ReportAdminResponseSchema,
SearchGuildsResponse,
},
types::{ReportEntry, SearchReportsResponse},
};
use parity_support::{
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
html_normalizer, rust_server,
};
use serde_json::{Value, json};
use std::{error::Error, io};
use tokio::net::TcpListener;
const SEARCH_REPORTS_V2: &str = include_str!("parity/fixtures/api/search_reports_v2.json");
const SEARCH_REPORTS_WEBHOOK: &str =
include_str!("parity/fixtures/api/search_reports_webhook.json");
const REPORT_WEBHOOK_DETAIL: &str = include_str!("parity/fixtures/api/report_webhook_detail.json");
const REPORT_EVIDENCE_DETAIL: &str =
include_str!("parity/fixtures/api/report_evidence_detail.json");
const EVIDENCE_REPORT_ID: &str = "1556352159220498613";
const EVIDENCE_BOT_ID: &str = "1556352159149195428";
const EVIDENCE_WEBHOOK_ID: &str = "1556352159132418208";
const EVIDENCE_DELETED_AUTHOR_ID: &str = "1556352159216304308";
const WEBHOOK_ID: &str = "1556114449176200401";
const WEBHOOK_REPORT_ID: &str = "1556114449264280806";
const BOT_REPORT_ID: &str = "1556114449268475111";
const BOT_USER_ID: &str = "1556114449192977621";
const WEBHOOK_CREATOR_ID: &str = "1556114449125868741";
#[test]
fn html_normalizer_canonicalizes_attribute_order_and_csrf_values() {
@@ -130,6 +161,473 @@ async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<()
Ok(())
}
#[test]
fn report_search_v2_fixture_matches_the_generated_and_hand_written_contracts() {
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_V2)
.expect("v2 report search fixture must match the generated response contract");
let flow = generated.reports[0]
.flow
.as_ref()
.expect("the v2 message report has answers");
assert_eq!(flow.surface, "in_app");
assert_eq!(flow.steps.len(), 4);
assert_eq!(generated.reports[0].reason.as_deref(), Some("csam"));
assert_eq!(generated.reports[2].reason, None);
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_V2)
.expect("v2 report search fixture must match the hand-written response type");
assert_eq!(response.total, 3);
let [message, user, legacy] = response.reports.as_slice() else {
panic!("expected three reports");
};
assert_eq!(
message.reason_label.as_deref(),
Some("Child sexual abuse material")
);
assert_eq!(message.reason_highest_priority, Some(true));
assert_eq!(message.category.as_deref(), Some("child_safety"));
let user_flow = user.flow.as_ref().expect("the v2 user report has answers");
assert_eq!(user.reason.as_deref(), Some("harassment"));
assert_eq!(user_flow.locale.as_deref(), Some("de"));
assert_eq!(user_flow.steps[0].screen_id, "profile_intro");
assert!(user_flow.steps[0].option_id.is_none());
assert!(user_flow.steps[0].items.is_empty());
assert_eq!(
user_flow.steps[1]
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["photo", "profile_text"]
);
assert!(legacy.reason.is_none());
assert!(legacy.reason_label.is_none());
assert!(legacy.flow.is_none());
assert!(legacy.reporter_good_faith_confirmed.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_renders_v2_report_fixture_with_reason_and_answers() -> Result<(), Box<dyn Error>>
{
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
let rust_admin = rust_server::start(&api_endpoint)
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let list = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports?reason=csam",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(list.status, 200, "{list:#?}");
assert!(list.body.contains(r#"name="reason""#), "{}", list.body);
assert!(
list.body.contains("Priority: Child sexual abuse material"),
"{}",
list.body
);
assert!(
list.body.contains(r#"data-report-reason="csam""#),
"{}",
list.body
);
assert!(
!list.body.contains(r#"data-report-reason="harassment""#),
"{}",
list.body
);
let detail = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports/1556008115705480394",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(detail.status, 200, "{detail:#?}");
for expected in [
"Report Answers",
"Report profile",
"Which parts of their profile are a problem?: Pictures, Profile text",
"Shown to the reporter in de",
"Surface: In app",
"Harassment or bullying",
] {
assert!(
detail.body.contains(expected),
"{expected}\n{}",
detail.body
);
}
let legacy = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports/1556008115709674699",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(legacy.status, 200, "{legacy:#?}");
assert!(!legacy.body.contains("Report Answers"), "{}", legacy.body);
assert!(
!legacy.body.contains("data-report-reason"),
"{}",
legacy.body
);
Ok(())
}
#[test]
fn report_webhook_fixtures_match_the_generated_and_hand_written_contracts() {
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
.expect("webhook report search fixture must match the generated response contract");
assert_eq!(generated.reports.len(), 2);
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
.expect("webhook report search fixture must match the hand-written response type");
let [webhook, bot] = response.reports.as_slice() else {
panic!("expected two reports");
};
assert_eq!(webhook.report_id, WEBHOOK_REPORT_ID);
assert!(webhook.reported_user_id.is_none());
assert_eq!(webhook.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
assert_eq!(
webhook.reported_webhook_name.as_deref(),
Some("Harbor Bulletin")
);
assert_eq!(bot.reported_user_id.as_deref(), Some(BOT_USER_ID));
assert!(bot.reported_webhook_id.is_none());
let generated_detail: ReportAdminResponseSchema = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
.expect("webhook report detail fixture must match the generated response contract");
let via_generated: ReportEntry = serde_json::from_value(
serde_json::to_value(generated_detail).expect("serialize generated detail"),
)
.expect("hand-written report type from the generated detail");
let direct: ReportEntry = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
.expect("webhook report detail fixture must match the hand-written report type");
for detail in [&via_generated, &direct] {
assert!(detail.reported_user_id.is_none());
assert_eq!(detail.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
assert_eq!(
detail.reported_webhook_creator_id.as_deref(),
Some(WEBHOOK_CREATOR_ID)
);
assert_eq!(
detail.reported_webhook_creator_global_name.as_deref(),
Some("Morgan Owner")
);
assert_eq!(detail.reported_webhook_type, Some(1));
assert_eq!(
detail.reported_webhook_channel_id,
detail.reported_channel_id
);
assert_eq!(detail.reported_webhook_guild_id, detail.reported_guild_id);
assert_eq!(
detail.reported_webhook_created_at.as_deref(),
Some("2026-10-04T01:23:15.892Z")
);
assert!(detail.reported_webhook_application_id.is_none());
let context = detail.message_context.as_ref().expect("message context");
let entry = context
.iter()
.find(|entry| entry["webhook_id"].as_str() == Some(WEBHOOK_ID))
.expect("the webhook message is in the context");
assert_eq!(entry["author_id"].as_str(), Some(WEBHOOK_ID));
assert_eq!(entry["author_username"].as_str(), Some("Harbor Bulletin"));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_renders_webhook_report_fixture() -> Result<(), Box<dyn Error>> {
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
let rust_admin = rust_server::start(&api_endpoint)
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let webhook_marker = format!(r#"data-report-webhook="{WEBHOOK_ID}""#);
let webhook_user_link = format!("/users/{WEBHOOK_ID}");
let list = capture::fetch_route(
&client,
rust_admin.base_url(),
&format!("/reports?reported_webhook_id={WEBHOOK_ID}"),
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(list.status, 200, "{list:#?}");
assert!(list.body.contains(WEBHOOK_REPORT_ID), "{}", list.body);
assert!(!list.body.contains(BOT_REPORT_ID), "{}", list.body);
assert!(list.body.contains(&webhook_marker), "{}", list.body);
assert!(list.body.contains("Harbor Bulletin"), "{}", list.body);
assert!(list.body.contains("Channel: general"), "{}", list.body);
assert!(!list.body.contains(&webhook_user_link), "{}", list.body);
let both = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports?reporter_id=1556114449113285826",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(both.status, 200, "{both:#?}");
assert!(
both.body
.contains(&format!(r#"href="/users/{BOT_USER_ID}""#)),
"{}",
both.body
);
let detail = capture::fetch_route(
&client,
rust_admin.base_url(),
&format!("/reports/{WEBHOOK_REPORT_ID}"),
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(detail.status, 200, "{detail:#?}");
for expected in [
webhook_marker.clone(),
">Webhook ID<".to_owned(),
format!(r#"href="/reports?reported_webhook_id={WEBHOOK_ID}""#),
"Harbor Commons".to_owned(),
"Message Context".to_owned(),
format!(r#"data-message-webhook="{WEBHOOK_ID}""#),
format!(r#"href="/users/{BOT_USER_ID}""#),
">Webhook Creator<".to_owned(),
format!(r#"data-report-webhook-creator="{WEBHOOK_CREATOR_ID}""#),
format!(r#"href="/users/{WEBHOOK_CREATOR_ID}""#),
"Morgan Owner".to_owned(),
">Webhook Type<".to_owned(),
">Incoming<".to_owned(),
">Webhook Created<".to_owned(),
"Oct 4, 2026, 1:23 AM UTC".to_owned(),
] {
assert!(
detail.body.contains(&expected),
"{expected}\n{}",
detail.body
);
}
assert!(!detail.body.contains(&webhook_user_link), "{}", detail.body);
assert!(
!detail.body.contains("View Reported User"),
"{}",
detail.body
);
for absent in [
">Webhook Channel ID<",
">Webhook Guild ID<",
">Webhook Record<",
"data-report-webhook-creator-deleted",
] {
assert!(!detail.body.contains(absent), "{absent}\n{}", detail.body);
}
Ok(())
}
#[test]
fn report_evidence_fixture_matches_the_generated_and_hand_written_contracts() {
let generated: ReportAdminResponseSchema = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
.expect("evidence report detail fixture must match the generated response contract");
let via_generated: ReportEntry = serde_json::from_value(
serde_json::to_value(generated).expect("serialize generated detail"),
)
.expect("hand-written report type from the generated detail");
let direct: ReportEntry = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
.expect("evidence report detail fixture must match the hand-written report type");
for detail in [&via_generated, &direct] {
assert_eq!(detail.reported_user_id.as_deref(), Some(EVIDENCE_BOT_ID));
assert_eq!(detail.reported_user_bot, Some(true));
let snapshot = detail
.reported_profile_snapshot
.as_ref()
.expect("the bot author has a profile snapshot");
let user = snapshot.user.as_ref().expect("user snapshot");
assert_eq!(user.id, EVIDENCE_BOT_ID);
assert_eq!(user.username.as_deref(), Some("Harbor_Helper"));
let member = snapshot.member.as_ref().expect("member snapshot");
assert_eq!(member.guild_id, "1556352159090475158");
assert!(member.joined_at.is_some());
assert!(snapshot.guild.is_none());
let context = detail.message_context.as_ref().expect("message context");
let author_bot = |author_id: &str| {
context
.iter()
.filter(|entry| entry["author_id"].as_str() == Some(author_id))
.map(|entry| entry["author_bot"].clone())
.collect::<Vec<_>>()
};
assert_eq!(author_bot(EVIDENCE_BOT_ID), [json!(true), json!(true)]);
assert_eq!(author_bot(EVIDENCE_WEBHOOK_ID), [Value::Null]);
assert_eq!(author_bot(EVIDENCE_DELETED_AUTHOR_ID), [Value::Null]);
assert_eq!(author_bot("1556352159069503633"), [json!(false)]);
assert!(context.iter().all(|entry| {
entry["missing_attachments"]
.as_array()
.is_none_or(Vec::is_empty)
}));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_renders_the_evidence_report_fixture() -> Result<(), Box<dyn Error>> {
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
let rust_admin = rust_server::start(&api_endpoint)
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let detail = capture::fetch_route(
&client,
rust_admin.base_url(),
&format!("/reports/{EVIDENCE_REPORT_ID}"),
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(detail.status, 200, "{detail:#?}");
for expected in [
format!(r#"data-report-user-bot="{EVIDENCE_BOT_ID}""#),
format!(r#"data-message-author-bot="{EVIDENCE_BOT_ID}""#),
format!(r#"data-message-webhook="{EVIDENCE_WEBHOOK_ID}""#),
"At Report Time".to_owned(),
"Harbor_Helper#3966".to_owned(),
">Community Profile<".to_owned(),
r#"data-report-legal-hold="none""#.to_owned(),
] {
assert!(
detail.body.contains(&expected),
"{expected}\n{}",
detail.body
);
}
assert_eq!(
detail.body.matches("data-message-author-bot=").count(),
1,
"{}",
detail.body
);
assert!(
!detail.body.contains("data-missing-attachment"),
"{}",
detail.body
);
Ok(())
}
async fn spawn_v2_report_api() -> Result<String, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|error| format!("failed to bind v2 report API: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("failed to read v2 report API address: {error}"))?
.port();
let app = Router::new()
.route(
"/admin/users/@me",
get(|| async {
fixture_json(include_str!("parity/fixtures/api/admin_user_me.json"))
}),
)
.route(
"/admin/report-reasons",
get(|| async {
Json(json!({"reasons": [
{"key": "csam", "label": "Child sexual abuse material", "highest_priority": true, "legacy_category_message": "child_safety", "legacy_category_user": "child_safety", "legacy_category_guild": "child_safety"},
{"key": "harassment", "label": "Harassment or bullying", "highest_priority": false, "legacy_category_message": "harassment", "legacy_category_user": "harassment", "legacy_category_guild": "harassment"}
]}))
.into_response()
}),
)
.route("/admin/reports", get(v2_report_search))
.route("/admin/reports/{report_id}", get(v2_report_detail));
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
Ok(format!("http://127.0.0.1:{port}"))
}
fn fixture_json(body: &'static str) -> Response {
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
Json(value).into_response()
}
fn v2_reports() -> Vec<Value> {
[SEARCH_REPORTS_V2, SEARCH_REPORTS_WEBHOOK]
.into_iter()
.flat_map(|body| {
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
value["reports"].as_array().cloned().unwrap_or_default()
})
.collect()
}
async fn v2_report_search(RawQuery(query): RawQuery) -> Response {
let query = query.unwrap_or_default();
let filters = url::form_urlencoded::parse(query.as_bytes())
.filter(|(key, value)| {
matches!(
key.as_ref(),
"reason" | "reporter_id" | "reported_webhook_id"
) && !value.is_empty()
})
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
let reports = v2_reports()
.into_iter()
.filter(|report| {
filters
.iter()
.all(|(key, value)| report[key.as_str()].as_str() == Some(value.as_str()))
})
.collect::<Vec<_>>();
Json(json!({"reports": reports, "total": reports.len(), "offset": 0, "limit": 25}))
.into_response()
}
async fn v2_report_detail(Path(report_id): Path<String>) -> Response {
if report_id == WEBHOOK_REPORT_ID {
return fixture_json(REPORT_WEBHOOK_DETAIL);
}
if report_id == EVIDENCE_REPORT_ID {
return fixture_json(REPORT_EVIDENCE_DETAIL);
}
match v2_reports()
.into_iter()
.find(|report| report["report_id"].as_str() == Some(report_id.as_str()))
{
Some(report) => Json(report).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
fn test_error(message: String) -> Box<dyn Error> {
Box::new(io::Error::other(message))
}