From 6536ffbc5c51270ff6d4196a7cd2c0a10105fdbd Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 09:48:58 -0700 Subject: [PATCH 1/2] feat(admin): show reported message content in report detail Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- admin-web/src/App.tsx | 38 ++++- admin-web/src/styles.css | 32 ++++ admin-web/src/types.ts | 11 ++ admin-web/tests/routes.spec.ts | 64 ++++++++ crates/buzz-db/src/admin_moderation.rs | 196 ++++++++++++++++++++++++- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-relay/src/api/admin/mod.rs | 17 ++- 7 files changed, 352 insertions(+), 8 deletions(-) diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index 49202e9778..f39ecc33c5 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -7,7 +7,12 @@ import { useState, } from "react"; import { ApiFailure, request } from "./api"; -import type { FeedbackDetail, FeedbackSummary, Report } from "./types"; +import type { + FeedbackDetail, + FeedbackSummary, + Report, + ReportDetail as ReportDetailData, +} from "./types"; import { useResource } from "./useResource"; function usePath() { @@ -131,7 +136,10 @@ function Reports() { } function ReportDetail({ id }: { id: string }) { - const resource = useResource(() => request(`/reports/${id}`), id); + const resource = useResource( + () => request(`/reports/${id}`), + id, + ); return ( {report.target} + {report.targetKind === "event" ? ( + <> +
Message
+
+ {report.message ? ( +
+ {report.message.deletedAt ? ( + Deleted + ) : null} +

{report.message.content}

+
+ Author + {report.message.authorPubkey} + Created + +
+
+ ) : ( +

+ Message content is unavailable. It may have expired or + been removed from event storage. +

+ )} +
+ + ) : null}
Note
{report.note ?? "No note provided."} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index f72747c0a7..93ebceeb14 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -496,6 +496,38 @@ dd { margin: 0; } +.reported-message { + display: grid; + justify-items: start; + gap: 1rem; + border-left: 3px solid #d7d72e; + border-radius: 0 0.8rem 0.8rem 0; + background: #f6f6f1; + padding: 1rem; +} + +.reported-message p, +.message-unavailable { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.5; + white-space: pre-wrap; +} + +.reported-message-meta { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.4rem 0.75rem; + width: 100%; + color: rgb(35 30 30 / 48%); + font-size: 0.78rem; +} + +.message-unavailable { + color: rgb(35 30 30 / 60%); + font-style: italic; +} + .sensitive { border-left: 3px solid #d7d72e; border-radius: 0 0.8rem 0.8rem 0; diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 1aab0cb9ac..368e19209b 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -12,6 +12,17 @@ export interface Report { createdAt: string; } +export interface ReportedMessage { + authorPubkey: string; + content: string; + createdAt: string; + deletedAt?: string; +} + +export interface ReportDetail extends Report { + message?: ReportedMessage; +} + export interface FeedbackSummary { id: string; communityId: string; diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts index 002ce456b3..e8e0aeda3f 100644 --- a/admin-web/tests/routes.spec.ts +++ b/admin-web/tests/routes.spec.ts @@ -58,6 +58,70 @@ test("report rows render the relay response contract", async ({ page }) => { await expect(page.getByText("Unknown date")).toHaveCount(0); }); +test("event report detail renders the reported message content", async ({ + page, +}) => { + const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08"; + await page.route(`**/api/admin/v1/reports/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc", + communityHost: "design.buzz.xyz", + reporterPubkey: "21".repeat(32), + targetKind: "event", + target: "12".repeat(32), + reportType: "spam", + status: "open", + createdAt: "2026-07-17T17:30:00Z", + message: { + authorPubkey: "31".repeat(32), + content: + "This is the complete reported message.\nIt preserves lines.", + createdAt: "2026-07-17T17:25:00Z", + }, + }), + }), + ); + + await page.goto(`/reports/${id}`); + await expect( + page.getByText("This is the complete reported message.", { exact: false }), + ).toBeVisible(); + await expect(page.getByText("31".repeat(32))).toBeVisible(); + await expect( + page.getByText("Message content is unavailable", { exact: false }), + ).toHaveCount(0); +}); + +test("event report detail explains when message content is unavailable", async ({ + page, +}) => { + const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09"; + await page.route(`**/api/admin/v1/reports/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc", + communityHost: "design.buzz.xyz", + reporterPubkey: "21".repeat(32), + targetKind: "event", + target: "12".repeat(32), + reportType: "spam", + status: "open", + createdAt: "2026-07-17T17:30:00Z", + }), + }), + ); + + await page.goto(`/reports/${id}`); + await expect( + page.getByText("Message content is unavailable", { exact: false }), + ).toBeVisible(); +}); + test("feedback cards open the complete submission", async ({ page }) => { const id = "feed0000-0000-4000-8000-000000000001"; const fullBody = `${"Long feedback ".repeat(30)}end of feedback`; diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/admin_moderation.rs index 2a0d276894..bcf62d6c83 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/admin_moderation.rs @@ -54,6 +54,31 @@ pub struct AdminReport { pub created_at: DateTime, } +/// Reported message details available only on the admin report detail read. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportedMessage { + /// Message author public key. + pub author_pubkey: String, + /// Complete message content. + pub content: String, + /// Timestamp signed into the message event. + pub created_at: DateTime, + /// Soft-deletion time, when the message has since been deleted. + pub deleted_at: Option>, +} + +/// Deployment-global moderation report detail. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportDetail { + /// Report metadata. + #[serde(flatten)] + pub report: AdminReport, + /// Reported message when the report targets a stored event. + pub message: Option, +} + /// Deployment-global product feedback with source-community provenance. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -128,24 +153,54 @@ pub async fn list_reports( rows.into_iter().map(row_to_report).collect() } -/// Fetch one report globally by its row id. -pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { +/// Fetch one report globally by its row id, including its event target content. +pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { let row = sqlx::query( r#" SELECT r.id, r.community_id, c.host AS community_host, r.report_event_id, r.reporter_pubkey, r.target_kind, r.target_event_id, r.target_pubkey, r.target_blob_sha256, r.channel_id, r.report_type, r.note, r.status, r.resolved_by, - r.resolved_at, r.action_id, r.created_at + r.resolved_at, r.action_id, r.created_at, + target.pubkey AS message_author_pubkey, + target.content AS message_content, + target.created_at AS message_created_at, + target.deleted_at AS message_deleted_at FROM moderation_reports r JOIN communities c ON c.id = r.community_id + LEFT JOIN LATERAL ( + SELECT e.pubkey, e.content, e.created_at, e.deleted_at + FROM events e + WHERE r.target_kind = 'event' + AND e.community_id = r.community_id + AND e.id = r.target_event_id + ORDER BY e.created_at DESC + LIMIT 1 + ) target ON TRUE WHERE r.id = $1 "#, ) .bind(report_id) .fetch_optional(pool) .await?; - row.map(row_to_report).transpose() + row.map(|row| { + let message = row + .try_get::>, _>("message_author_pubkey")? + .map(|author_pubkey| -> Result { + Ok(AdminReportedMessage { + author_pubkey: hex::encode(author_pubkey), + content: row.try_get("message_content")?, + created_at: row.try_get("message_created_at")?, + deleted_at: row.try_get("message_deleted_at")?, + }) + }) + .transpose()?; + Ok(AdminReportDetail { + report: row_to_report(row)?, + message, + }) + }) + .transpose() } fn row_to_report(row: sqlx::postgres::PgRow) -> Result { @@ -228,3 +283,136 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result { received_at: row.try_get("received_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn insert_community(pool: &PgPool, label: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("admin-report-{label}-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_event( + pool: &PgPool, + community_id: Uuid, + event_id: &[u8], + author: &[u8], + content: &str, + deleted_at: Option>, + ) { + sqlx::query( + r#" + INSERT INTO events ( + community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at + ) VALUES ($1, $2, $3, $4, 9, '[]'::jsonb, $5, $6, $7) + "#, + ) + .bind(community_id) + .bind(event_id) + .bind(author) + .bind(Utc::now()) + .bind(content) + .bind(vec![3_u8; 64]) + .bind(deleted_at) + .execute(pool) + .await + .expect("insert event"); + } + + async fn insert_event_report( + pool: &PgPool, + community_id: Uuid, + target_event_id: &[u8], + ) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, id, report_event_id, reporter_pubkey, + target_kind, target_event_id, report_type + ) VALUES ($1, $2, $3, $4, 'event', $5, 'spam') + "#, + ) + .bind(community_id) + .bind(id) + .bind(Uuid::new_v4().as_bytes().repeat(2)) + .bind(vec![4_u8; 32]) + .bind(target_event_id) + .execute(pool) + .await + .expect("insert report"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_reads_only_the_same_community_target_and_includes_deleted_content() { + let pool = setup_pool().await; + let report_community = insert_community(&pool, "reported").await; + let other_community = insert_community(&pool, "other").await; + let event_id = vec![1_u8; 32]; + let deleted_at = Utc::now(); + insert_event( + &pool, + report_community, + &event_id, + &[5_u8; 32], + "reported message", + Some(deleted_at), + ) + .await; + insert_event( + &pool, + other_community, + &event_id, + &[6_u8; 32], + "wrong tenant message", + None, + ) + .await; + let report_id = insert_event_report(&pool, report_community, &event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + let message = detail.message.expect("reported message exists"); + assert_eq!(message.content, "reported message"); + assert_eq!(message.author_pubkey, hex::encode([5_u8; 32])); + assert!(message.deleted_at.is_some()); + + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(report_community) + .execute(&pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM events WHERE community_id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete event fixtures"); + sqlx::query("DELETE FROM communities WHERE id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete community fixtures"); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9c63b2e8ab..06fec5f4ce 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -563,7 +563,7 @@ impl Db { pub async fn admin_get_report( &self, id: Uuid, - ) -> Result> { + ) -> Result> { admin_moderation::get_report(&self.pool, id).await } diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index f0f4c5e633..1ae99f324d 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -126,7 +126,7 @@ async fn report_detail( State(state): State>, headers: HeaderMap, Path(id): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { authorize(&state, &headers)?; state .db @@ -374,6 +374,21 @@ mod tests { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + #[tokio::test] + async fn report_detail_requires_admin_host_before_database_access() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "community.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + } + #[tokio::test] async fn feedback_attachment_requires_admin_host_before_database_access() { let response = router(test_state().await) From b97611725b010ed8437f661cfe6cc95ea2a6a872 Mon Sep 17 00:00:00 2001 From: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 11:27:35 -0700 Subject: [PATCH 2/2] test(admin): cover unavailable report targets Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- admin-web/src/types.ts | 4 +- admin-web/tests/routes.spec.ts | 2 + crates/buzz-db/src/admin_moderation.rs | 70 ++++++++++++++++++++++++++ crates/buzz-relay/src/api/admin/mod.rs | 15 ++++++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 368e19209b..6c10837758 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -16,11 +16,11 @@ export interface ReportedMessage { authorPubkey: string; content: string; createdAt: string; - deletedAt?: string; + deletedAt: string | null; } export interface ReportDetail extends Report { - message?: ReportedMessage; + message: ReportedMessage | null; } export interface FeedbackSummary { diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts index e8e0aeda3f..3c965dd2d8 100644 --- a/admin-web/tests/routes.spec.ts +++ b/admin-web/tests/routes.spec.ts @@ -80,6 +80,7 @@ test("event report detail renders the reported message content", async ({ content: "This is the complete reported message.\nIt preserves lines.", createdAt: "2026-07-17T17:25:00Z", + deletedAt: null, }, }), }), @@ -112,6 +113,7 @@ test("event report detail explains when message content is unavailable", async ( reportType: "spam", status: "open", createdAt: "2026-07-17T17:30:00Z", + message: null, }), }), ); diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/admin_moderation.rs index bcf62d6c83..31efaca362 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/admin_moderation.rs @@ -362,6 +362,40 @@ mod tests { id } + async fn insert_pubkey_report(pool: &PgPool, community_id: Uuid) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, id, report_event_id, reporter_pubkey, + target_kind, target_pubkey, report_type + ) VALUES ($1, $2, $3, $4, 'pubkey', $5, 'spam') + "#, + ) + .bind(community_id) + .bind(id) + .bind(Uuid::new_v4().as_bytes().repeat(2)) + .bind(vec![4_u8; 32]) + .bind(vec![7_u8; 32]) + .execute(pool) + .await + .expect("insert report"); + id + } + + async fn delete_report_fixture(pool: &PgPool, community_id: Uuid) { + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(community_id) + .execute(pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(pool) + .await + .expect("delete community fixture"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn report_detail_reads_only_the_same_community_target_and_includes_deleted_content() { @@ -415,4 +449,40 @@ mod tests { .await .expect("delete community fixtures"); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_has_no_message_for_non_event_target() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "pubkey-target").await; + let report_id = insert_pubkey_report(&pool, community_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.report.target_kind, "pubkey"); + assert!(detail.message.is_none()); + + delete_report_fixture(&pool, community_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_has_no_message_when_event_row_is_missing() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "missing-event").await; + let missing_event_id = vec![8_u8; 32]; + let report_id = insert_event_report(&pool, community_id, &missing_event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.report.target_kind, "event"); + assert_eq!(detail.report.target, hex::encode(missing_event_id)); + assert!(detail.message.is_none()); + + delete_report_fixture(&pool, community_id).await; + } } diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 1ae99f324d..21f30065f0 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -389,6 +389,21 @@ mod tests { assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); } + #[tokio::test] + async fn report_detail_rejects_unknown_report() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + } + #[tokio::test] async fn feedback_attachment_requires_admin_host_before_database_access() { let response = router(test_state().await)