mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): probe role via /probe, refresh lists, gate kick, toast errors
The desktop admin console had four e2e defects. Probe discarded role/source: it hit /reports and hardcoded role: None behind a comment referencing X-Admin-Role headers the relay never sends, so the Staffing tab and role badges were unreachable for operators. Probe now targets the signed /probe endpoint and parses the ProbeResponse contract, carrying the relay-resolved role/source through. Reports and Feedback lists served stale rows after a detail-view mutation until a tab switch forced a remount. Each tab now bumps a list generation fence on mutation completion so back-nav refetches. Kick was offered on every event report, but the relay 400s it when the report carries no channel. It is now suppressed when channelId is null. Mutation errors rendered raw admin API error JSON inline; they now route through toast.error with the parsed relay message. The seed script inserted no channel_id on any report, so the failed-enforcement -> Cancel & reopen path was unreachable locally. It now seeds a channel and an event report carrying it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -36,6 +36,10 @@ pub(crate) mod routes;
|
||||
/// can reach the 256 KiB event-content cap. Sized for the worst case.
|
||||
const SUCCESS_JSON_CAP: u64 = 52_428_800; // 50 MiB
|
||||
|
||||
/// Probe-response cap: `/probe` returns a tiny fixed-shape JSON envelope.
|
||||
/// 8 KiB is far more than the payload needs while bounding a hostile body.
|
||||
const PROBE_JSON_CAP: u64 = 8_192; // 8 KiB
|
||||
|
||||
/// Error-body cap: relay error responses are brief JSON envelopes.
|
||||
const ERROR_BODY_CAP: u64 = 65_536; // 64 KiB
|
||||
|
||||
@@ -104,13 +108,13 @@ type SignFn = Box<dyn Fn(&str) -> Result<String, String> + Send + Sync>;
|
||||
/// current app keypair is authorized.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Send an unauthenticated GET to `/api/admin/v1/reports?limit=1`.
|
||||
/// 1. Send an unauthenticated GET to `/api/admin/v1/probe`.
|
||||
/// 2. Detect HTML/interception pages (Cloudflare Access, captive portals)
|
||||
/// from Content-Type and final URL host → `NetworkOrIntercepted`.
|
||||
/// 3. 200 + valid JSON list shape → `Disabled` (admin accessible without cred).
|
||||
/// 3. 200 + valid `ProbeResponse` with `authMode: "disabled"` → `Disabled`.
|
||||
/// 4. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly
|
||||
/// signed kind-27235. 200 + valid list shape → `Nip98Authorized`;
|
||||
/// non-200 → `Nip98Denied`.
|
||||
/// signed kind-27235. 200 + valid `ProbeResponse` → `Nip98Authorized`
|
||||
/// carrying the relay-resolved `role`/`source`; non-200 → `Nip98Denied`.
|
||||
/// 5. 401 + `WWW-Authenticate: Bearer` → `TokenMode`.
|
||||
/// 6. 403/404 or other non-401 → `NotAdminApi`.
|
||||
/// 7. Network/redirect/TLS error → `NetworkOrIntercepted`.
|
||||
@@ -144,13 +148,7 @@ async fn admin_probe_inner(
|
||||
sign: Option<impl Fn(&str) -> Result<String, String>>,
|
||||
) -> Result<AdminProbeResult, String> {
|
||||
let origin = origin::AdminOrigin::parse(origin)?;
|
||||
let url = origin.route_url(
|
||||
&routes::AdminRoute::ReportsList,
|
||||
&routes::AdminQuery {
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let url = origin.route_url(&routes::AdminRoute::Probe, &routes::AdminQuery::default());
|
||||
|
||||
let http_client = client::ADMIN_CLIENT
|
||||
.get()
|
||||
@@ -174,15 +172,16 @@ async fn admin_probe_inner(
|
||||
return Ok(AdminProbeResult::NetworkOrIntercepted);
|
||||
}
|
||||
|
||||
// Step 3: success without auth → disabled mode (if body is a valid list).
|
||||
// Step 3: success without auth → disabled mode (if body is a valid probe).
|
||||
if resp.status().is_success() {
|
||||
let content_type = response_content_type(&resp);
|
||||
let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await?;
|
||||
return if looks_like_admin_list(&content_type, &bytes) {
|
||||
Ok(AdminProbeResult::Disabled)
|
||||
} else {
|
||||
Ok(AdminProbeResult::NotAdminApi)
|
||||
};
|
||||
let bytes = read_bounded(resp, PROBE_JSON_CAP).await?;
|
||||
return Ok(match parse_probe(&content_type, &bytes) {
|
||||
Some(p) if p.auth_mode == "disabled" => AdminProbeResult::Disabled,
|
||||
// A 200 in any other mode is a contract violation (token/nip98
|
||||
// must 401 an unauthenticated caller); classify defensively.
|
||||
_ => AdminProbeResult::NotAdminApi,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4–6: interpret 401.
|
||||
@@ -221,21 +220,19 @@ async fn admin_probe_inner(
|
||||
}
|
||||
|
||||
if auth_resp.status().is_success() {
|
||||
// Validate the Nostr header shape was accepted (not just any 2xx).
|
||||
let content_type = response_content_type(&auth_resp);
|
||||
let bytes = read_bounded(auth_resp, SUCCESS_JSON_CAP).await?;
|
||||
return if looks_like_admin_list(&content_type, &bytes) {
|
||||
// Extract role and source from probe response headers if present.
|
||||
// The relay includes X-Admin-Role and X-Admin-Source on the
|
||||
// authenticated probe response once the principal is resolved.
|
||||
Ok(AdminProbeResult::Nip98Authorized {
|
||||
role: None,
|
||||
source: None,
|
||||
})
|
||||
} else {
|
||||
// Endpoint exists but didn't return the expected list shape.
|
||||
Ok(AdminProbeResult::NotAdminApi)
|
||||
};
|
||||
let bytes = read_bounded(auth_resp, PROBE_JSON_CAP).await?;
|
||||
return Ok(match parse_probe(&content_type, &bytes) {
|
||||
// Relay resolves role/source for the authenticated
|
||||
// principal; carry them through for the staffing tab.
|
||||
Some(p) => AdminProbeResult::Nip98Authorized {
|
||||
role: p.role,
|
||||
source: p.source,
|
||||
},
|
||||
// 2xx but not a probe shape: endpoint exists but isn't
|
||||
// the admin API.
|
||||
None => AdminProbeResult::NotAdminApi,
|
||||
});
|
||||
}
|
||||
return Ok(AdminProbeResult::Nip98Denied);
|
||||
}
|
||||
@@ -296,83 +293,42 @@ async fn read_bounded(resp: reqwest::Response, cap: u64) -> Result<Vec<u8>, Stri
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Returns true when `content_type` is JSON and `bytes` deserialises to a
|
||||
/// JSON array matching the `/api/admin/v1/reports` shape.
|
||||
/// The relay's `/probe` response contract (`ProbeResponse` in the relay's
|
||||
/// `api/admin/mod.rs`, serialised `rename_all = "camelCase"`).
|
||||
///
|
||||
/// Rules:
|
||||
/// - Content-Type must start with `application/json` (case-insensitive).
|
||||
/// - Body must be a JSON array.
|
||||
/// - Non-empty arrays must have every element deserialise against the
|
||||
/// `AdminReport` wire contract (camelCase, `rename_all = "camelCase"`).
|
||||
/// An empty array is valid — a fresh relay with no reports returns `[]`.
|
||||
/// - Partial / garbage elements (`{"id":null}`, `7`, `"garbage"`) are rejected.
|
||||
/// Only the fields the desktop consumes are typed. `role`/`source` are
|
||||
/// present (non-null) only in nip98 mode with a resolved principal; they are
|
||||
/// `null` in token/disabled modes.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProbeWire {
|
||||
#[allow(dead_code)]
|
||||
status: String,
|
||||
auth_mode: String,
|
||||
role: Option<String>,
|
||||
source: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
can_act: bool,
|
||||
#[allow(dead_code)]
|
||||
can_staff: bool,
|
||||
}
|
||||
|
||||
/// Parse a `/probe` response body into a [`ProbeWire`], returning `None` when
|
||||
/// the Content-Type is not JSON or the body does not match the probe contract.
|
||||
///
|
||||
/// This prevents unrelated endpoints that return JSON arrays from being
|
||||
/// misclassified as the admin API.
|
||||
fn looks_like_admin_list(content_type: &str, bytes: &[u8]) -> bool {
|
||||
// Require JSON Content-Type.
|
||||
/// Strict typing rejects unrelated JSON endpoints: a response missing any
|
||||
/// required field (`status`, `authMode`, `canAct`, `canStaff`) or carrying a
|
||||
/// wrong-typed field fails to deserialise and yields `None`, so a non-admin
|
||||
/// origin that happens to return JSON is classified `NotAdminApi` rather than
|
||||
/// mistaken for the admin API.
|
||||
fn parse_probe(content_type: &str, bytes: &[u8]) -> Option<ProbeWire> {
|
||||
if !content_type
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("application/json")
|
||||
{
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// Body must be a JSON array.
|
||||
let arr = match serde_json::from_slice::<serde_json::Value>(bytes) {
|
||||
Ok(serde_json::Value::Array(a)) => a,
|
||||
_ => return false,
|
||||
};
|
||||
// Empty array is valid (fresh relay with no reports).
|
||||
if arr.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// Non-empty: every element must deserialise against the AdminReport probe DTO.
|
||||
// The wire shape is camelCase (serde rename_all = "camelCase").
|
||||
arr.iter()
|
||||
.all(|v| serde_json::from_value::<AdminReportProbeDto>(v.clone()).is_ok())
|
||||
}
|
||||
|
||||
/// Full `AdminReport` wire contract used for probe validation.
|
||||
///
|
||||
/// Mirrors the camelCase serialisation of `AdminReport` in
|
||||
/// `crates/buzz-db/src/admin_moderation.rs:24-55` exactly — required fields
|
||||
/// are typed strictly, optional fields use `Option<T>` with real types.
|
||||
/// This ensures that a response with `createdAt: null` or a malformed optional
|
||||
/// field (e.g. `channelId: 7`) is rejected, not silently classified as the
|
||||
/// admin API.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AdminReportProbeDto {
|
||||
#[allow(dead_code)]
|
||||
id: uuid::Uuid,
|
||||
#[allow(dead_code)]
|
||||
community_id: uuid::Uuid,
|
||||
#[allow(dead_code)]
|
||||
community_host: String,
|
||||
#[allow(dead_code)]
|
||||
report_event_id: String,
|
||||
#[allow(dead_code)]
|
||||
reporter_pubkey: String,
|
||||
#[allow(dead_code)]
|
||||
target_kind: String,
|
||||
#[allow(dead_code)]
|
||||
target: String,
|
||||
#[allow(dead_code)]
|
||||
channel_id: Option<uuid::Uuid>,
|
||||
#[allow(dead_code)]
|
||||
report_type: String,
|
||||
#[allow(dead_code)]
|
||||
note: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
status: String,
|
||||
#[allow(dead_code)]
|
||||
resolved_by: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
resolved_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[allow(dead_code)]
|
||||
action_id: Option<uuid::Uuid>,
|
||||
#[allow(dead_code)]
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
serde_json::from_slice::<ProbeWire>(bytes).ok()
|
||||
}
|
||||
|
||||
/// Extract the normalised Content-Type base value (strips parameters).
|
||||
|
||||
@@ -94,150 +94,63 @@ fn content_type_matching_is_case_insensitive_and_strips_params() {
|
||||
assert_eq!(normalised, "image/png");
|
||||
}
|
||||
|
||||
// ── looks_like_admin_list ─────────────────────────────────────────────────
|
||||
// ── parse_probe ───────────────────────────────────────────────────────────
|
||||
|
||||
fn valid_admin_report_json(id: &str) -> String {
|
||||
/// A well-formed `/probe` response body. `role`/`source` are JSON literals
|
||||
/// (`"operator"`, `null`, …) so the helper can build both nip98 and
|
||||
/// token/disabled shapes.
|
||||
fn probe_json(auth_mode: &str, role: &str, source: &str, can_act: bool, can_staff: bool) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"id": "{id}",
|
||||
"communityId": "00000000-0000-0000-0000-000000000002",
|
||||
"communityHost": "relay.example.com",
|
||||
"reportEventId": "aabbcc",
|
||||
"reporterPubkey": "ddeeff",
|
||||
"targetKind": "message",
|
||||
"target": "112233",
|
||||
"channelId": null,
|
||||
"reportType": "spam",
|
||||
"note": null,
|
||||
"status": "open",
|
||||
"resolvedBy": null,
|
||||
"resolvedAt": null,
|
||||
"actionId": null,
|
||||
"createdAt": "2024-01-01T00:00:00Z"
|
||||
}}"#
|
||||
r#"{{"status":"ok","authMode":"{auth_mode}","role":{role},"source":{source},"canAct":{can_act},"canStaff":{can_staff}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_empty_array_with_json_ct() {
|
||||
assert!(looks_like_admin_list("application/json", b"[]"));
|
||||
fn parse_probe_operator_nip98() {
|
||||
let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true);
|
||||
let p = parse_probe("application/json", body.as_bytes()).expect("valid operator probe");
|
||||
assert_eq!(p.auth_mode, "nip98");
|
||||
assert_eq!(p.role.as_deref(), Some("operator"));
|
||||
assert_eq!(p.source.as_deref(), Some("config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_valid_report_element() {
|
||||
let body = format!(
|
||||
"[{}]",
|
||||
valid_admin_report_json("00000000-0000-0000-0000-000000000001")
|
||||
);
|
||||
assert!(
|
||||
looks_like_admin_list("application/json", body.as_bytes()),
|
||||
"single valid AdminReport element must classify as admin list"
|
||||
);
|
||||
fn parse_probe_disabled_has_null_role() {
|
||||
let body = probe_json("disabled", "null", "null", false, false);
|
||||
let p = parse_probe("application/json", body.as_bytes()).expect("valid disabled probe");
|
||||
assert_eq!(p.auth_mode, "disabled");
|
||||
assert_eq!(p.role, None);
|
||||
assert_eq!(p.source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_id_null() {
|
||||
// {"id":null} passes the old `contains_key("id")` check but must be rejected
|
||||
// because `null` is not a valid UUID.
|
||||
assert!(!looks_like_admin_list(
|
||||
"application/json",
|
||||
b"[{\"id\":null}]"
|
||||
));
|
||||
fn parse_probe_rejects_non_json_content_type() {
|
||||
let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true);
|
||||
assert!(parse_probe("text/html", body.as_bytes()).is_none());
|
||||
assert!(parse_probe("", body.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_created_at_null() {
|
||||
// Full-shape element with `createdAt: null` must be rejected — the wire
|
||||
// contract requires a real RFC-3339 timestamp for `createdAt`.
|
||||
let body = r#"[{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"communityId": "00000000-0000-0000-0000-000000000002",
|
||||
"communityHost": "relay.example.com",
|
||||
"reportEventId": "aabbcc",
|
||||
"reporterPubkey": "ddeeff",
|
||||
"targetKind": "message",
|
||||
"target": "112233",
|
||||
"channelId": null,
|
||||
"reportType": "spam",
|
||||
"note": null,
|
||||
"status": "open",
|
||||
"resolvedBy": null,
|
||||
"resolvedAt": null,
|
||||
"actionId": null,
|
||||
"createdAt": null
|
||||
}]"#;
|
||||
assert!(
|
||||
!looks_like_admin_list("application/json", body.as_bytes()),
|
||||
"full-shape element with createdAt: null must not classify as admin list"
|
||||
);
|
||||
fn parse_probe_rejects_missing_required_field() {
|
||||
// Missing `canStaff` — an unrelated JSON endpoint must not classify as the
|
||||
// admin API.
|
||||
let body =
|
||||
r#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":true}"#;
|
||||
assert!(parse_probe("application/json", body.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_malformed_optional_field() {
|
||||
// Full-shape element with a malformed optional UUID field (`channelId: 7`)
|
||||
// must be rejected — the wire contract requires Option<Uuid> for channelId.
|
||||
let body = r#"[{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"communityId": "00000000-0000-0000-0000-000000000002",
|
||||
"communityHost": "relay.example.com",
|
||||
"reportEventId": "aabbcc",
|
||||
"reporterPubkey": "ddeeff",
|
||||
"targetKind": "message",
|
||||
"target": "112233",
|
||||
"channelId": 7,
|
||||
"reportType": "spam",
|
||||
"note": null,
|
||||
"status": "open",
|
||||
"resolvedBy": null,
|
||||
"resolvedAt": null,
|
||||
"actionId": null,
|
||||
"createdAt": "2024-01-01T00:00:00Z"
|
||||
}]"#;
|
||||
assert!(
|
||||
!looks_like_admin_list("application/json", body.as_bytes()),
|
||||
"full-shape element with channelId: 7 (not a UUID) must not classify as admin list"
|
||||
);
|
||||
fn parse_probe_rejects_wrong_typed_field() {
|
||||
// `canAct` as a string, not a bool.
|
||||
let body = r#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":"yes","canStaff":true}"#;
|
||||
assert!(parse_probe("application/json", body.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_garbage_fixture() {
|
||||
// Pinned fixture from the spec: [{"id":null}, 7, "garbage"] must be rejected.
|
||||
assert!(!looks_like_admin_list(
|
||||
"application/json",
|
||||
b"[{\"id\":null}, 7, \"garbage\"]"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_primitive_array() {
|
||||
assert!(!looks_like_admin_list("application/json", b"[1]"));
|
||||
assert!(!looks_like_admin_list(
|
||||
"application/json",
|
||||
b"[\"unrelated\"]"
|
||||
));
|
||||
assert!(!looks_like_admin_list(
|
||||
"application/json",
|
||||
b"[{\"notId\":true}]"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_non_json_content_type() {
|
||||
assert!(!looks_like_admin_list("text/html", b"[]"));
|
||||
assert!(!looks_like_admin_list("", b"[]"));
|
||||
assert!(!looks_like_admin_list("text/plain", b"[]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_admin_list_rejects_non_array() {
|
||||
assert!(!looks_like_admin_list("application/json", b"{}"));
|
||||
assert!(!looks_like_admin_list("application/json", b"\"string\""));
|
||||
assert!(!looks_like_admin_list("application/json", b"null"));
|
||||
assert!(!looks_like_admin_list(
|
||||
"application/json",
|
||||
b"<html>captive portal</html>"
|
||||
));
|
||||
assert!(!looks_like_admin_list("application/json", b"not json"));
|
||||
fn parse_probe_rejects_non_object() {
|
||||
assert!(parse_probe("application/json", b"[]").is_none());
|
||||
assert!(parse_probe("application/json", b"\"string\"").is_none());
|
||||
assert!(parse_probe("application/json", b"not json").is_none());
|
||||
}
|
||||
|
||||
// ── Storage core through production code ─────────────────────────────────
|
||||
@@ -570,29 +483,31 @@ async fn probe_html_200_classified_as_intercepted() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_json_200_not_classified_as_intercepted() {
|
||||
let resp = fake_response(200, "Content-Type: application/json\r\n", "[]").await;
|
||||
let resp = fake_response(
|
||||
200,
|
||||
"Content-Type: application/json\r\n",
|
||||
r#"{"status":"ok","authMode":"disabled","role":null,"source":null,"canAct":false,"canStaff":false}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(!is_probe_response_intercepted(&resp));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_json_200_with_valid_report_looks_like_admin_list() {
|
||||
let body = format!(
|
||||
"[{}]",
|
||||
valid_admin_report_json("00000000-0000-0000-0000-000000000001")
|
||||
);
|
||||
async fn probe_json_200_with_valid_probe_parses() {
|
||||
let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true);
|
||||
let resp = fake_response(200, "Content-Type: application/json\r\n", &body).await;
|
||||
assert!(!is_probe_response_intercepted(&resp));
|
||||
let ct = response_content_type(&resp);
|
||||
let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap();
|
||||
assert!(looks_like_admin_list(&ct, &bytes));
|
||||
let bytes = read_bounded(resp, PROBE_JSON_CAP).await.unwrap();
|
||||
assert!(parse_probe(&ct, &bytes).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_json_200_bare_array_of_garbage_not_admin_api() {
|
||||
async fn probe_json_200_bare_garbage_not_admin_api() {
|
||||
let resp = fake_response(200, "Content-Type: application/json\r\n", "[1,2,3]").await;
|
||||
let ct = response_content_type(&resp);
|
||||
let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap();
|
||||
assert!(!looks_like_admin_list(&ct, &bytes));
|
||||
let bytes = read_bounded(resp, PROBE_JSON_CAP).await.unwrap();
|
||||
assert!(parse_probe(&ct, &bytes).is_none());
|
||||
}
|
||||
|
||||
// ── admin_probe_inner end-to-end state machine ────────────────────────────
|
||||
@@ -632,8 +547,15 @@ async fn probe_inner_malformed_json_200_is_not_admin_api() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_inner_json_empty_array_200_is_disabled() {
|
||||
let addr = serve_sequence(vec![("200 OK", "Content-Type: application/json\r\n", "[]")]).await;
|
||||
async fn probe_inner_disabled_probe_200_is_disabled() {
|
||||
let body = probe_json("disabled", "null", "null", false, false);
|
||||
let body_static: &'static str = Box::leak(body.into_boxed_str());
|
||||
let addr = serve_sequence(vec![(
|
||||
"200 OK",
|
||||
"Content-Type: application/json\r\n",
|
||||
body_static,
|
||||
)])
|
||||
.await;
|
||||
let result = admin_probe_inner(
|
||||
&format!("http://{addr}"),
|
||||
None::<fn(&str) -> Result<String, String>>,
|
||||
@@ -644,8 +566,30 @@ async fn probe_inner_json_empty_array_200_is_disabled() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_inner_bare_array_of_garbage_is_not_admin_api() {
|
||||
// Non-empty arrays without valid AdminReport elements must not classify as admin API.
|
||||
async fn probe_inner_nip98_authmode_200_without_auth_is_not_admin_api() {
|
||||
// A relay must 401 an unauthenticated caller in nip98/token mode. A 200
|
||||
// carrying `authMode: "nip98"` (no 401 challenge) is a contract violation
|
||||
// and must not be classified as Disabled.
|
||||
let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true);
|
||||
let body_static: &'static str = Box::leak(body.into_boxed_str());
|
||||
let addr = serve_sequence(vec![(
|
||||
"200 OK",
|
||||
"Content-Type: application/json\r\n",
|
||||
body_static,
|
||||
)])
|
||||
.await;
|
||||
let result = admin_probe_inner(
|
||||
&format!("http://{addr}"),
|
||||
None::<fn(&str) -> Result<String, String>>,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, AdminProbeResult::NotAdminApi));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_inner_bare_garbage_200_is_not_admin_api() {
|
||||
// A JSON body that isn't a probe envelope must not classify as admin API.
|
||||
let addr = serve_sequence(vec![(
|
||||
"200 OK",
|
||||
"Content-Type: application/json\r\n",
|
||||
@@ -661,27 +605,6 @@ async fn probe_inner_bare_array_of_garbage_is_not_admin_api() {
|
||||
assert!(matches!(result, AdminProbeResult::NotAdminApi));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_inner_garbage_fixture_id_null_and_primitives_is_not_admin_api() {
|
||||
// Pinned fixture from the spec: must be rejected.
|
||||
let addr = serve_sequence(vec![(
|
||||
"200 OK",
|
||||
"Content-Type: application/json\r\n",
|
||||
"[{\"id\":null}, 7, \"garbage\"]",
|
||||
)])
|
||||
.await;
|
||||
let result = admin_probe_inner(
|
||||
&format!("http://{addr}"),
|
||||
None::<fn(&str) -> Result<String, String>>,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, AdminProbeResult::NotAdminApi),
|
||||
"garbage fixture must be NotAdminApi, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_inner_persistent_401_is_nip98_denied() {
|
||||
let addr = serve_sequence(vec![
|
||||
@@ -712,10 +635,7 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut
|
||||
let expected_token = "Nostr dGVzdA==".to_string();
|
||||
let expected_token_for_sign = expected_token.clone();
|
||||
|
||||
let valid_body = format!(
|
||||
"[{}]",
|
||||
valid_admin_report_json("00000000-0000-0000-0000-000000000003")
|
||||
);
|
||||
let valid_body = probe_json("nip98", r#""operator""#, r#""config""#, true, true);
|
||||
let valid_body_static: &'static str = Box::leak(valid_body.into_boxed_str());
|
||||
|
||||
// serve_gated_nip98: slot 0 always challenges; slot 1 checks the Authorization
|
||||
@@ -727,9 +647,15 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The relay-resolved role/source must be carried through to the UI so the
|
||||
// Staffing tab renders for an operator.
|
||||
assert!(
|
||||
matches!(result, AdminProbeResult::Nip98Authorized { .. }),
|
||||
"expected Nip98Authorized, got {result:?}"
|
||||
matches!(
|
||||
&result,
|
||||
AdminProbeResult::Nip98Authorized { role, source }
|
||||
if role.as_deref() == Some("operator") && source.as_deref() == Some("config")
|
||||
),
|
||||
"expected Nip98Authorized operator/config, got {result:?}"
|
||||
);
|
||||
|
||||
let records = records.lock().unwrap();
|
||||
@@ -742,8 +668,8 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut
|
||||
records[0].method
|
||||
);
|
||||
assert!(
|
||||
records[0].path.contains("/api/admin/v1/reports"),
|
||||
"slot-0 must target the reports endpoint; got {:?}",
|
||||
records[0].path.contains("/api/admin/v1/probe"),
|
||||
"slot-0 must target the probe endpoint; got {:?}",
|
||||
records[0].path
|
||||
);
|
||||
assert!(
|
||||
@@ -761,8 +687,8 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut
|
||||
records[1].method
|
||||
);
|
||||
assert!(
|
||||
records[1].path.contains("/api/admin/v1/reports"),
|
||||
"slot-1 must target the reports endpoint; got {:?}",
|
||||
records[1].path.contains("/api/admin/v1/probe"),
|
||||
"slot-1 must target the probe endpoint; got {:?}",
|
||||
records[1].path
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -43,6 +43,9 @@ impl AttachmentHash {
|
||||
/// are validated hex strings.
|
||||
#[derive(Debug)]
|
||||
pub enum AdminRoute {
|
||||
/// Auth-mode/role/capability discovery. Requires no DB and returns role
|
||||
/// `null` in token/disabled modes.
|
||||
Probe,
|
||||
ReportsList,
|
||||
ReportDetail {
|
||||
id: uuid::Uuid,
|
||||
@@ -104,6 +107,7 @@ impl AdminRoute {
|
||||
/// Return the URL path component (not including the `/api/admin/v1` prefix).
|
||||
pub fn path(&self) -> String {
|
||||
match self {
|
||||
AdminRoute::Probe => "/probe".to_string(),
|
||||
AdminRoute::ReportsList => "/reports".to_string(),
|
||||
AdminRoute::ReportDetail { id } => format!("/reports/{id}"),
|
||||
AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"),
|
||||
@@ -245,6 +249,11 @@ mod tests {
|
||||
assert_eq!(AdminRoute::ReportsList.path(), "/reports");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_path() {
|
||||
assert_eq!(AdminRoute::Probe.path(), "/probe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_detail_path() {
|
||||
let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||
|
||||
@@ -46,11 +46,15 @@ export function FeedbackTab({
|
||||
generation: number;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// List refresh fence: bumped when a feedback status change completes in the
|
||||
// detail view, so returning to the list shows fresh status without a tab
|
||||
// switch.
|
||||
const [listGen, setListGen] = useState(0);
|
||||
|
||||
const listState: AsyncState<AdminFeedbackSummaryDto[]> = useAsyncLoad(
|
||||
() => listAdminFeedback(origin),
|
||||
[origin, pubkey],
|
||||
generation,
|
||||
generation + listGen,
|
||||
);
|
||||
|
||||
if (selectedId) {
|
||||
@@ -61,6 +65,7 @@ export function FeedbackTab({
|
||||
origin={origin}
|
||||
pubkey={pubkey}
|
||||
generation={generation}
|
||||
onMutated={() => setListGen((g) => g + 1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -383,12 +388,15 @@ export function FeedbackDetail({
|
||||
generation,
|
||||
feedbackId,
|
||||
onBack,
|
||||
onMutated,
|
||||
}: {
|
||||
origin: string;
|
||||
pubkey: string;
|
||||
generation: number;
|
||||
feedbackId: string;
|
||||
onBack: () => void;
|
||||
/** Called after a status change completes so the parent list can refetch. */
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
// Local status state: initialized from server, updated on PATCH.
|
||||
const [localStatus, setLocalStatus] = useState<AdminFeedbackStatus | null>(
|
||||
@@ -439,7 +447,10 @@ export function FeedbackDetail({
|
||||
feedbackId={feedbackId}
|
||||
currentStatus={localStatus}
|
||||
origin={origin}
|
||||
onStatusChanged={setLocalStatus}
|
||||
onStatusChanged={(newStatus) => {
|
||||
setLocalStatus(newStatus);
|
||||
onMutated();
|
||||
}}
|
||||
/>
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
CommunityGroupedList,
|
||||
formatTimestamp,
|
||||
useAsyncLoad,
|
||||
adminErrorMessage,
|
||||
} from "./AdminConsolePanelHelpers";
|
||||
import { FeedbackTab } from "./AdminConsoleFeedbackTab";
|
||||
import { StaffingTab } from "./AdminConsoleStaffingTab";
|
||||
@@ -179,7 +180,6 @@ function EnforcementStateBlock({
|
||||
reportId: string;
|
||||
onActionComplete: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
|
||||
const actionStatus = activeAction.status;
|
||||
@@ -194,7 +194,6 @@ function EnforcementStateBlock({
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
// Fence the cancel to the exact failed action the operator observed. On
|
||||
@@ -208,10 +207,8 @@ function EnforcementStateBlock({
|
||||
} catch (e) {
|
||||
// A 409 means the action is no longer cancellable (already cancelled,
|
||||
// superseded, or past the mutation point). Reload detail rather than
|
||||
// retry — the message is informational, the reload shows current state.
|
||||
setError(
|
||||
`Cancel rejected: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
// retry — the toast is informational, the reload shows current state.
|
||||
toast.error(`Cancel rejected: ${adminErrorMessage(e)}`);
|
||||
onActionComplete();
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
@@ -255,7 +252,6 @@ function EnforcementStateBlock({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -285,15 +281,18 @@ function ResolveReportForm({
|
||||
const [reason, setReason] = useState("");
|
||||
const [expirationSecs, setExpirationSecs] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Stable requestId per submission; regenerated on each new submit attempt.
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
const allowedActions = allowedActionsForTargetKind(report.targetKind ?? "");
|
||||
// Kick removes the target from the report's associated channel, so the
|
||||
// relay rejects it (400 invalid_action_for_target) when the report carries
|
||||
// no channel. Suppress it client-side rather than offer a guaranteed failure.
|
||||
const allowedActions = allowedActionsForTargetKind(
|
||||
report.targetKind ?? "",
|
||||
).filter((a) => a !== "kick" || report.channelId != null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedAction) return;
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Generate a fresh requestId for this submission attempt (v4 amendment 2).
|
||||
@@ -321,7 +320,7 @@ function ResolveReportForm({
|
||||
if (!msg.includes("409") && !msg.includes("processing")) {
|
||||
requestIdRef.current = null;
|
||||
}
|
||||
setError(msg);
|
||||
toast.error(adminErrorMessage(e));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -405,8 +404,6 @@ function ResolveReportForm({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -437,7 +434,6 @@ function ReopenReportForm({
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Stable requestId per attempt; reused on retry after a lost response.
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -445,7 +441,6 @@ function ReopenReportForm({
|
||||
const wasEnforced = report.actionId != null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (!requestIdRef.current) {
|
||||
@@ -467,7 +462,7 @@ function ReopenReportForm({
|
||||
if (!msg.includes("409") && !msg.includes("processing")) {
|
||||
requestIdRef.current = null;
|
||||
}
|
||||
setError(msg);
|
||||
toast.error(adminErrorMessage(e));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -513,8 +508,6 @@ function ReopenReportForm({
|
||||
"Reopen report"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -531,11 +524,14 @@ function ReportsTab({
|
||||
generation: number;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// List refresh fence: bumped whenever a mutation completes in the detail
|
||||
// view, so returning to the list shows fresh status without a tab switch.
|
||||
const [listGen, setListGen] = useState(0);
|
||||
|
||||
const listState = useAsyncLoad(
|
||||
() => listAdminReports(origin),
|
||||
[origin, pubkey],
|
||||
generation,
|
||||
generation + listGen,
|
||||
);
|
||||
|
||||
if (selectedId) {
|
||||
@@ -546,6 +542,7 @@ function ReportsTab({
|
||||
generation={generation}
|
||||
reportId={selectedId}
|
||||
onBack={() => setSelectedId(null)}
|
||||
onMutated={() => setListGen((g) => g + 1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -650,16 +647,26 @@ function ReportDetail({
|
||||
generation,
|
||||
reportId,
|
||||
onBack,
|
||||
onMutated,
|
||||
}: {
|
||||
origin: string;
|
||||
pubkey: string;
|
||||
generation: number;
|
||||
reportId: string;
|
||||
onBack: () => void;
|
||||
/** Called after any mutation completes so the parent list can refetch. */
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
// Resolution generation: bump to reload detail after an action completes.
|
||||
const [resolveGen, setResolveGen] = useState(0);
|
||||
|
||||
// Reload the detail AND signal the parent list on every completed mutation,
|
||||
// so back-nav shows fresh status without the tab-switch workaround.
|
||||
const handleMutated = () => {
|
||||
setResolveGen((g) => g + 1);
|
||||
onMutated();
|
||||
};
|
||||
|
||||
const detailState = useAsyncLoad(
|
||||
() => getAdminReport(origin, reportId),
|
||||
[origin, pubkey, reportId],
|
||||
@@ -703,7 +710,7 @@ function ReportDetail({
|
||||
activeAction={activeAction}
|
||||
origin={origin}
|
||||
reportId={reportId}
|
||||
onActionComplete={() => setResolveGen((g) => g + 1)}
|
||||
onActionComplete={handleMutated}
|
||||
/>
|
||||
)}
|
||||
{/* Resolve form: shown for open reports. A reopened-after-enforcement
|
||||
@@ -716,7 +723,7 @@ function ReportDetail({
|
||||
<ResolveReportForm
|
||||
report={detailState.data}
|
||||
origin={origin}
|
||||
onResolved={() => setResolveGen((g) => g + 1)}
|
||||
onResolved={handleMutated}
|
||||
/>
|
||||
)}
|
||||
{/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */}
|
||||
@@ -724,7 +731,7 @@ function ReportDetail({
|
||||
<ReopenReportForm
|
||||
report={detailState.data}
|
||||
origin={origin}
|
||||
onReopened={() => setResolveGen((g) => g + 1)}
|
||||
onReopened={handleMutated}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -63,6 +63,31 @@ export function useAsyncLoad<T>(
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Admin error message parsing ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from an admin mutation error.
|
||||
*
|
||||
* Native admin commands reject with `admin API error: {json}` where the JSON
|
||||
* is the relay's error envelope (`{"error":{"code","message","requestId"}}`).
|
||||
* This strips the prefix and returns the envelope's `message` field so the UI
|
||||
* can surface "action kick requires the report to have an associated channel"
|
||||
* instead of the raw JSON. Falls back to the raw string when the payload is
|
||||
* not the expected shape (network errors, non-JSON bodies).
|
||||
*/
|
||||
export function adminErrorMessage(e: unknown): string {
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
const jsonStart = raw.indexOf("{");
|
||||
if (jsonStart === -1) return raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw.slice(jsonStart));
|
||||
const message = parsed?.error?.message;
|
||||
return typeof message === "string" && message.length > 0 ? message : raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared UI helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export function LoadingSpinner() {
|
||||
|
||||
@@ -93,6 +93,13 @@ toast.success = (msg) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** @type {string[]} */
|
||||
const capturedErrorToasts = [];
|
||||
toast.error = (msg) => {
|
||||
capturedErrorToasts.push(String(msg));
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ── Deferred promise helper ──────────────────────────────────────────────────
|
||||
|
||||
function deferred() {
|
||||
@@ -174,6 +181,7 @@ async function settle(ms = 20) {
|
||||
afterEach(() => {
|
||||
clearIpcHandlers();
|
||||
capturedToasts.length = 0;
|
||||
capturedErrorToasts.length = 0;
|
||||
});
|
||||
|
||||
// ── origin-edit ──────────────────────────────────────────────────────────────
|
||||
@@ -2366,16 +2374,14 @@ test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same
|
||||
);
|
||||
|
||||
// No success toast on a 409.
|
||||
assert.deepEqual(
|
||||
capturedToasts,
|
||||
[],
|
||||
assert.ok(
|
||||
!capturedToasts.some((m) => m.toLowerCase().includes("reopen")),
|
||||
`no success toast on a 409; got: ${JSON.stringify(capturedToasts)}`,
|
||||
);
|
||||
// The error is surfaced.
|
||||
const text = container.textContent ?? "";
|
||||
// The error is surfaced via toast.error with the parsed relay message.
|
||||
assert.ok(
|
||||
text.includes("not reopenable"),
|
||||
`the 409 error message must surface; got: ${text.slice(0, 400)}`,
|
||||
capturedErrorToasts.some((m) => m.includes("not reopenable")),
|
||||
`the 409 error message must surface via toast.error; got: ${JSON.stringify(capturedErrorToasts)}`,
|
||||
);
|
||||
|
||||
await unmount();
|
||||
@@ -2768,3 +2774,328 @@ test("feedback-severed-community: a purged-source feedback row renders in list a
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
// ── D3a: kick suppressed when the report carries no channel ────────────────
|
||||
|
||||
test("kick-suppressed-when-channel-null: an event report without a channel hides the Kick action", async () => {
|
||||
// Kick removes the target from the report's associated channel, so the relay
|
||||
// 400s (invalid_action_for_target) when the report has no channelId. The
|
||||
// resolve form must not offer an action guaranteed to fail. Other event
|
||||
// actions (ban/timeout/dismiss/delete/escalate) stay available.
|
||||
//
|
||||
// Mutation evidence: drop the `.filter((a) => a !== "kick" || channelId
|
||||
// != null)` guard → action-btn-kick renders and the null-channel assertion
|
||||
// goes red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "d3".repeat(32);
|
||||
|
||||
const item = {
|
||||
id: "00000000-0000-0000-0000-0000000000d3",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const detail = {
|
||||
...item,
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([item]));
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(detail));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
assert.ok(
|
||||
container.querySelector("[data-testid='resolve-report-form']"),
|
||||
"resolve form must render for an open report",
|
||||
);
|
||||
assert.equal(
|
||||
container.querySelector("[data-testid='action-btn-kick']"),
|
||||
null,
|
||||
"Kick must be suppressed when the report has no channelId",
|
||||
);
|
||||
// Sibling event actions remain available — only Kick is gated.
|
||||
assert.ok(
|
||||
container.querySelector("[data-testid='action-btn-ban']"),
|
||||
"Ban must still be offered on an event report",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("kick-offered-when-channel-set: an event report with a channel offers the Kick action", async () => {
|
||||
// The paired case: when the report carries a channelId, Kick is a valid
|
||||
// action (the relay can enforce it) and must be offered.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "d4".repeat(32);
|
||||
|
||||
const item = {
|
||||
id: "00000000-0000-0000-0000-0000000000d4",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const detail = {
|
||||
...item,
|
||||
channelId: "00000000-0000-0000-0000-0000000000ff",
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([item]));
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(detail));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
assert.ok(
|
||||
container.querySelector("[data-testid='action-btn-kick']"),
|
||||
"Kick must be offered when the report carries a channelId",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
// ── D2: lists refetch on back-nav after a mutation ─────────────────────────
|
||||
|
||||
test("reports-list-refetches-on-back-after-mutation: resolving a report then navigating back shows fresh list status", async () => {
|
||||
// A mutation in the detail bumps a list generation fence propagated to the
|
||||
// ReportsTab, so returning to the list refetches instead of serving the
|
||||
// stale cached rows (Will's tab-switch workaround). Evidence is a second
|
||||
// admin_list_reports call after back-nav returning the updated status.
|
||||
//
|
||||
// Mutation evidence: drop the onMutated → setListGen wiring → the list
|
||||
// query key never changes, admin_list_reports is called once, and the
|
||||
// second-call assertion goes red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "d5".repeat(32);
|
||||
|
||||
const openItem = {
|
||||
id: "00000000-0000-0000-0000-0000000000d5",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "pubkey",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const openDetail = {
|
||||
...openItem,
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
// The list returns "open" first, then "dismissed" after the mutation — the
|
||||
// refetch must surface the new status.
|
||||
let listCalls = 0;
|
||||
setIpcHandler("admin_list_reports", () => {
|
||||
listCalls += 1;
|
||||
return Promise.resolve([
|
||||
{ ...openItem, status: listCalls === 1 ? "open" : "dismissed" },
|
||||
]);
|
||||
});
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(openDetail));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
setIpcHandler("admin_resolve_report", () =>
|
||||
Promise.resolve({ status: "dismissed" }),
|
||||
);
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
const callsBeforeBack = listCalls;
|
||||
|
||||
// Dismiss the report.
|
||||
const dismissBtn = container.querySelector(
|
||||
"[data-testid='action-btn-dismiss']",
|
||||
);
|
||||
assert.ok(dismissBtn, "dismiss action must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
const submit = container.querySelector("[data-testid='resolve-submit-btn']");
|
||||
assert.ok(
|
||||
submit,
|
||||
"resolve submit button must appear after selecting dismiss",
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
// Navigate back to the list.
|
||||
const backBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Back to reports"),
|
||||
);
|
||||
assert.ok(backBtn, "back-to-reports button must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(backBtn);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
assert.ok(
|
||||
listCalls > callsBeforeBack,
|
||||
`the list must refetch after back-nav following a mutation; before=${callsBeforeBack} after=${listCalls}`,
|
||||
);
|
||||
assert.ok(
|
||||
(container.textContent ?? "").includes("dismissed"),
|
||||
`the refetched list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`,
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("feedback-list-refetches-on-back-after-mutation: changing status then navigating back shows fresh list status", async () => {
|
||||
// Same fence for the Feedback tab: a status change in the detail bumps the
|
||||
// FeedbackTab list generation so back-nav refetches.
|
||||
//
|
||||
// Mutation evidence: drop the FeedbackDetail onMutated → setListGen wiring →
|
||||
// admin_list_feedback is called once and the second-call assertion goes red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "d6".repeat(32);
|
||||
|
||||
const summary = {
|
||||
id: "00000000-0000-0000-0000-0000000000d6",
|
||||
communityId: "00000000-0000-0000-0000-000000000022",
|
||||
communityHost: "relay.example.com",
|
||||
submitterPubkey: "submitter",
|
||||
category: "bug",
|
||||
bodySummary: "App crashes on startup",
|
||||
status: "new",
|
||||
receivedAt: "2024-05-01T09:00:05Z",
|
||||
};
|
||||
const detail = {
|
||||
id: summary.id,
|
||||
communityId: summary.communityId,
|
||||
communityHost: summary.communityHost,
|
||||
eventId: "feedevent",
|
||||
submitterPubkey: summary.submitterPubkey,
|
||||
category: "bug",
|
||||
body: "App crashes on startup — full detail",
|
||||
status: "new",
|
||||
tags: [],
|
||||
eventCreatedAt: "2024-05-01T09:00:00Z",
|
||||
receivedAt: "2024-05-01T09:00:05Z",
|
||||
};
|
||||
|
||||
let listCalls = 0;
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
||||
setIpcHandler("admin_list_feedback", () => {
|
||||
listCalls += 1;
|
||||
return Promise.resolve([
|
||||
{ ...summary, status: listCalls === 1 ? "new" : "reviewed" },
|
||||
]);
|
||||
});
|
||||
setIpcHandler("admin_get_feedback", () => Promise.resolve(detail));
|
||||
setIpcHandler("admin_patch_feedback", () =>
|
||||
Promise.resolve({ status: "reviewed" }),
|
||||
);
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
|
||||
// Switch to the Feedback tab.
|
||||
const feedbackTab = container.querySelector(
|
||||
"[data-testid='admin-tab-feedback']",
|
||||
);
|
||||
assert.ok(feedbackTab, "Feedback tab must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(feedbackTab);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
assert.equal(listCalls, 1, "feedback list is fetched once on tab open");
|
||||
|
||||
// Open the first feedback row.
|
||||
const row = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) =>
|
||||
!(b.getAttribute("data-testid") ?? "").startsWith("admin-tab") &&
|
||||
b.textContent?.includes("App crashes"),
|
||||
);
|
||||
assert.ok(row, "feedback row must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(row);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
// Mark reviewed.
|
||||
const reviewedBtn = container.querySelector(
|
||||
"[data-testid='feedback-status-btn-reviewed']",
|
||||
);
|
||||
assert.ok(reviewedBtn, "reviewed status button must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(reviewedBtn);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
// Navigate back to the feedback list.
|
||||
const backBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Back to feedback"),
|
||||
);
|
||||
assert.ok(backBtn, "back-to-feedback button must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(backBtn);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
assert.ok(
|
||||
listCalls >= 2,
|
||||
`the feedback list must refetch after back-nav following a status change; listCalls=${listCalls}`,
|
||||
);
|
||||
assert.ok(
|
||||
(container.textContent ?? "").includes("reviewed"),
|
||||
`the refetched feedback list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`,
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Unit tests for adminErrorMessage — the parser that turns a native admin
|
||||
* mutation rejection into the human-readable text surfaced via toast.error.
|
||||
*
|
||||
* Native admin commands reject with `admin API error: {json}` where the JSON
|
||||
* is the relay's error envelope. The parser strips the prefix and returns the
|
||||
* envelope's `message`, falling back to the raw string for anything that is
|
||||
* not that shape (network errors, plain strings, malformed JSON).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { adminErrorMessage } from "./AdminConsolePanelHelpers.tsx";
|
||||
|
||||
test("extracts-envelope-message: returns the relay error message, not the raw JSON", () => {
|
||||
const raw =
|
||||
'admin API error: {"error":{"code":"invalid_action_for_target","message":"action kick requires the report to have an associated channel","requestId":"abc"}}';
|
||||
assert.equal(
|
||||
adminErrorMessage(new Error(raw)),
|
||||
"action kick requires the report to have an associated channel",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts-raw-string-input: parses when passed a string rather than an Error", () => {
|
||||
const raw =
|
||||
'admin API error: {"error":{"message":"report is not open (current status: processing)"}}';
|
||||
assert.equal(
|
||||
adminErrorMessage(raw),
|
||||
"report is not open (current status: processing)",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls-back-on-non-json: a plain network error returns its raw text", () => {
|
||||
assert.equal(
|
||||
adminErrorMessage(new Error("Failed to fetch")),
|
||||
"Failed to fetch",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls-back-on-malformed-json: an unparseable brace payload returns raw", () => {
|
||||
const raw = "admin API error: {not valid json";
|
||||
assert.equal(adminErrorMessage(new Error(raw)), raw);
|
||||
});
|
||||
|
||||
test("falls-back-on-empty-message: an envelope with a blank message returns raw", () => {
|
||||
const raw = 'admin API error: {"error":{"code":"x","message":""}}';
|
||||
assert.equal(adminErrorMessage(new Error(raw)), raw);
|
||||
});
|
||||
|
||||
test("falls-back-on-absent-message: an envelope without a message field returns raw", () => {
|
||||
const raw = 'admin API error: {"error":{"code":"internal"}}';
|
||||
assert.equal(adminErrorMessage(new Error(raw)), raw);
|
||||
});
|
||||
@@ -128,27 +128,43 @@ BEGIN
|
||||
RAISE EXCEPTION 'local community is missing; run just setup first';
|
||||
END IF;
|
||||
|
||||
-- A real channel for the failed-enforcement report below. Kick is only valid
|
||||
-- on `event` reports, and the relay rejects it pre-mutation unless the report
|
||||
-- carries a channel_id (FK into channels). Seeding this channel makes the
|
||||
-- Kick action reachable in the UI and lets the enforcement genuinely run and
|
||||
-- fail, so the Cancel & reopen recovery path is exercisable locally.
|
||||
INSERT INTO channels (community_id, id, name, created_by)
|
||||
VALUES (
|
||||
local_community_id,
|
||||
'c4a11e10-0000-4000-8000-000000000001',
|
||||
'seed-enforcement-channel',
|
||||
decode(repeat('3b', 32), 'hex')
|
||||
)
|
||||
ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name;
|
||||
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, id, report_event_id, reporter_pubkey, target_kind,
|
||||
target_event_id, target_pubkey, target_blob_sha256, report_type, note,
|
||||
target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note,
|
||||
status, resolved_by, resolved_at, created_at
|
||||
) VALUES
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days')
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), NULL, 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), NULL, 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), NULL, 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000011', decode(repeat('0b', 32), 'hex'), decode(repeat('1b', 32), 'hex'), 'event', decode(repeat('2b', 32), 'hex'), NULL, NULL, 'c4a11e10-0000-4000-8000-000000000001', 'spam', 'Event report in a real channel — Kick is offered and the enforcement genuinely runs and fails, exercising the Cancel & reopen recovery path.', 'open', NULL, NULL, now() - interval '3 minutes')
|
||||
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
|
||||
reporter_pubkey = EXCLUDED.reporter_pubkey,
|
||||
target_kind = EXCLUDED.target_kind,
|
||||
target_event_id = EXCLUDED.target_event_id,
|
||||
target_pubkey = EXCLUDED.target_pubkey,
|
||||
target_blob_sha256 = EXCLUDED.target_blob_sha256,
|
||||
channel_id = EXCLUDED.channel_id,
|
||||
report_type = EXCLUDED.report_type,
|
||||
note = EXCLUDED.note,
|
||||
status = EXCLUDED.status,
|
||||
@@ -191,4 +207,4 @@ sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnosti
|
||||
|
||||
run_psql -v ON_ERROR_STOP=1 -c "${sql}"
|
||||
|
||||
echo "Seeded 10 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."
|
||||
echo "Seeded 11 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."
|
||||
|
||||
Reference in New Issue
Block a user