From 4eedc33a8f1fb14117ec20d06d9ed62fb088406d Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Fri, 14 Aug 2026 16:39:44 -0400 Subject: [PATCH] fix(desktop): preserve idempotency key on ambiguous mutation failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolve/reopen retry paths preserved their idempotency requestId only when the error string contained "409"/"processing". The native layer serializes lost-response failures as "relay unreachable: …" and "admin response stream error", so every ambiguous transport/read failure cleared the UUID and the retry became a brand-new command — defeating idempotency on exactly the failures it exists to contain (two-operator interleave: A reopens, response lost, B resolves, A's retry with a fresh UUID clobbers B's later action). Replace the string match with a typed AdminMutationError carrying the relay's HTTP status. The UI preserves the requestId unless the relay definitively rejected the request pre-commit (a non-409 4xx); it preserves on null status (transport/pre-send), 409, 5xx, and a lost response body, so the relay dedupes the retry. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/admin/error.rs | 49 +++++ .../src-tauri/src/commands/admin/helpers.rs | 85 +++++++- desktop/src-tauri/src/commands/admin/mod.rs | 25 ++- .../admin-console/AdminConsolePanel.tsx | 22 +- .../AdminConsolePanelHelpers.tsx | 47 +++++ .../adminConsolePanelEvents.jsdom-test.mjs | 192 +++++++++++++++++- 6 files changed, 382 insertions(+), 38 deletions(-) create mode 100644 desktop/src-tauri/src/commands/admin/error.rs diff --git a/desktop/src-tauri/src/commands/admin/error.rs b/desktop/src-tauri/src/commands/admin/error.rs new file mode 100644 index 000000000..9af5dd6bb --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/error.rs @@ -0,0 +1,49 @@ +//! Typed error for admin mutation commands. + +/// Error from an admin mutation command, carrying whether the relay +/// authoritatively answered so the UI can decide idempotency-retry policy +/// without string-matching the message. +/// +/// `relayStatus` is `Some(code)` only when the relay returned an HTTP status — +/// the request reached the relay and it answered. It is `None` for a +/// pre-response transport failure (`send()` error, DNS/connect/timeout) or a +/// pre-send failure (auth build, body serialisation): the relay never +/// committed anything, so a retry must reuse the same idempotency key. +/// +/// A body-read failure mid-stream keeps the status it was reading (the relay +/// answered with headers/status but the body was lost) — the outcome is +/// unknown, so the caller preserves idempotency and lets the retry dedupe. +/// +/// Serialises `rename_all = "camelCase"`; the JS bridge surfaces it as the +/// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus`. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminMutationError { + /// Human-readable message — byte-identical to the string the command + /// produced before typing, so existing message parsing is unaffected. + pub message: String, + /// The relay's HTTP status when a response was received; `None` for a + /// transport/pre-send failure where no relay answer exists. + pub relay_status: Option, +} + +impl AdminMutationError { + /// The relay answered with an HTTP status. + pub(super) fn relay(status: reqwest::StatusCode, message: String) -> Self { + Self { + message, + relay_status: Some(status.as_u16()), + } + } +} + +/// Pre-send and transport failures carry no relay status: the relay never saw +/// the request (or never answered), so the outcome is unambiguously "no commit". +impl From for AdminMutationError { + fn from(message: String) -> Self { + Self { + message, + relay_status: None, + } + } +} diff --git a/desktop/src-tauri/src/commands/admin/helpers.rs b/desktop/src-tauri/src/commands/admin/helpers.rs index 39ac19b42..309a0cd26 100644 --- a/desktop/src-tauri/src/commands/admin/helpers.rs +++ b/desktop/src-tauri/src/commands/admin/helpers.rs @@ -4,7 +4,7 @@ //! used by the Tauri command implementations in `mod.rs`. use super::client; -use super::{ATTACHMENT_CAP, ERROR_BODY_CAP}; +use super::{AdminMutationError, ATTACHMENT_CAP, ERROR_BODY_CAP}; /// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap. pub(super) async fn fetch_admin_json( @@ -51,7 +51,7 @@ pub(super) async fn post_admin_json( body: &[u8], cap: u64, state: &tauri::State<'_, crate::app_state::AppState>, -) -> Result, String> { +) -> Result, AdminMutationError> { mutation_admin_json(reqwest::Method::POST, url, body, cap, state).await } @@ -61,7 +61,7 @@ pub(super) async fn patch_admin_json( body: &[u8], cap: u64, state: &tauri::State<'_, crate::app_state::AppState>, -) -> Result, String> { +) -> Result, AdminMutationError> { mutation_admin_json(reqwest::Method::PATCH, url, body, cap, state).await } @@ -71,7 +71,7 @@ pub(super) async fn put_admin_json( body: &[u8], cap: u64, state: &tauri::State<'_, crate::app_state::AppState>, -) -> Result, String> { +) -> Result, AdminMutationError> { mutation_admin_json(reqwest::Method::PUT, url, body, cap, state).await } @@ -115,13 +115,19 @@ pub(super) async fn delete_admin_json( } /// Shared implementation for POST/PATCH/PUT with NIP-98 payload sha256 binding. +/// +/// Returns a typed [`AdminMutationError`] so the caller can distinguish a +/// relay-authoritative failure (a status was received) from a transport or +/// pre-send failure (no relay answer). `?` on the `String`-producing steps +/// (auth build, `send()` classification) converts via `From` to a +/// no-status error, which is correct: none of those reached a relay verdict. pub(super) async fn mutation_admin_json( method: reqwest::Method, url: &str, body: &[u8], cap: u64, state: &tauri::State<'_, crate::app_state::AppState>, -) -> Result, String> { +) -> Result, AdminMutationError> { use crate::relay::build_nip98_auth_header_for_keys; let keys = state.signing_keys()?; @@ -153,10 +159,10 @@ pub(super) async fn mutation_admin_json( let resp2 = send_request(auth_header2) .await .map_err(|e| crate::relay::classify_request_error(&e))?; - return read_admin_response(resp2, cap, ERROR_BODY_CAP).await; + return read_admin_mutation_response(resp2, cap, ERROR_BODY_CAP).await; } - read_admin_response(resp, cap, ERROR_BODY_CAP).await + read_admin_mutation_response(resp, cap, ERROR_BODY_CAP).await } /// Stream and validate an attachment response, enforcing Content-Type, size, @@ -270,3 +276,68 @@ pub(super) async fn read_admin_response( Ok(bytes) } + +/// Read a mutation response, preserving the relay's HTTP status in the error. +/// +/// Mirrors [`read_admin_response`]'s size discipline and message wording so the +/// UI's message parsing is unchanged, but on a non-2xx it returns an +/// [`AdminMutationError`] tagged with the received status. A redirect or a +/// body-read failure also carries the status: the relay answered (headers/ +/// status arrived), the body outcome is unknown, so the caller preserves the +/// idempotency key and lets the retry dedupe against any commit that landed. +async fn read_admin_mutation_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, AdminMutationError> { + use futures_util::StreamExt; + + let status = resp.status(); + + if status.is_redirection() { + return Err(AdminMutationError::relay( + status, + format!("admin API returned a {status} redirect (not followed)"), + )); + } + + let (is_success, cap) = if status.is_success() { + (true, success_cap) + } else { + (false, error_cap) + }; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(AdminMutationError::relay( + status, + format!("admin response too large ({cl} bytes, cap {cap} bytes)"), + )); + } + } + + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + AdminMutationError::relay(status, format!("admin response stream error: {e}")) + })?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(AdminMutationError::relay( + status, + format!("admin response too large (cap {cap} bytes)"), + )); + } + bytes.extend_from_slice(&chunk); + } + + if !is_success { + let body = String::from_utf8_lossy(&bytes); + return Err(AdminMutationError::relay( + status, + format!("admin API error: {body}"), + )); + } + + Ok(bytes) +} diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index cd69bb60d..8927ae5ce 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -53,6 +53,11 @@ use helpers::{ post_admin_json, put_admin_json, }; +// ── Typed mutation error ────────────────────────────────────────────────── + +pub(crate) mod error; +pub use error::AdminMutationError; + // ── Typed probe result ──────────────────────────────────────────────────── /// Result of an `admin_probe` call. Each variant maps to a distinct UI state. @@ -523,7 +528,7 @@ pub async fn admin_resolve_report( id: String, body: serde_json::Value, state: tauri::State<'_, crate::app_state::AppState>, -) -> Result { +) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let id = uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; @@ -534,7 +539,7 @@ pub async fn admin_resolve_report( let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; - serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) } /// Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen. @@ -550,7 +555,7 @@ pub async fn admin_reopen_report( id: String, body: serde_json::Value, state: tauri::State<'_, crate::app_state::AppState>, -) -> Result { +) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let id = uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; @@ -561,7 +566,7 @@ pub async fn admin_reopen_report( let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; - serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) } /// Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel. @@ -577,7 +582,7 @@ pub async fn admin_cancel_report( id: String, body: serde_json::Value, state: tauri::State<'_, crate::app_state::AppState>, -) -> Result { +) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let id = uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; @@ -588,7 +593,7 @@ pub async fn admin_cancel_report( let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; - serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) } /// Update feedback status — PATCH /api/admin/v1/feedback/{id}. @@ -600,7 +605,7 @@ pub async fn admin_patch_feedback( id: String, body: serde_json::Value, state: tauri::State<'_, crate::app_state::AppState>, -) -> Result { +) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let id = uuid::Uuid::parse_str(&id).map_err(|_| "feedback id must be a valid UUID".to_string())?; @@ -611,7 +616,7 @@ pub async fn admin_patch_feedback( let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; let bytes = patch_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; - serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) } /// List operators — GET /api/admin/v1/operators. @@ -642,7 +647,7 @@ pub async fn admin_put_operator( pubkey: String, body: serde_json::Value, state: tauri::State<'_, crate::app_state::AppState>, -) -> Result { +) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let pubkey = routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; @@ -653,7 +658,7 @@ pub async fn admin_put_operator( let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; let bytes = put_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; - serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) } /// Remove an operator — DELETE /api/admin/v1/operators/{pubkey}. diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 3d126572e..8f2afd71a 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -55,6 +55,7 @@ import { formatTimestamp, useAsyncLoad, adminErrorMessage, + preserveRequestIdOnError, } from "./AdminConsolePanelHelpers"; import { FeedbackTab } from "./AdminConsoleFeedbackTab"; import { StaffingTab } from "./AdminConsoleStaffingTab"; @@ -313,11 +314,12 @@ function ResolveReportForm({ toast.success(`Report resolved: ${actionLabel(selectedAction)}`); onResolved(); } catch (e) { - // On error, reset requestId so the next submit generates a new one. - // But: if the error suggests a 409 (report already processing), the - // server has a claim — don't reset, let the parent handle it. - const msg = e instanceof Error ? e.message : String(e); - if (!msg.includes("409") && !msg.includes("processing")) { + // Preserve the requestId whenever the outcome is ambiguous (409, + // 5xx, a lost response, or a transport failure with no relay answer) + // so a retry reuses the same idempotency key and the relay dedupes. + // Reset only on a definitive pre-commit rejection (a non-409 4xx), where + // a corrected resubmission is a genuinely new command. + if (!preserveRequestIdOnError(e)) { requestIdRef.current = null; } toast.error(adminErrorMessage(e)); @@ -455,11 +457,11 @@ function ReopenReportForm({ toast.success("Report reopened"); onReopened(); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - // A 409 means the report is not reopenable (e.g. it moved to - // `processing`). Preserve the requestId so a genuine retry reuses it; - // reset otherwise so the next attempt generates a fresh one. - if (!msg.includes("409") && !msg.includes("processing")) { + // Preserve the requestId on an ambiguous outcome (409, 5xx, lost + // response, or a transport failure with no relay answer) so a retry + // reuses the same idempotency key; reset only on a definitive pre-commit + // rejection (a non-409 4xx). + if (!preserveRequestIdOnError(e)) { requestIdRef.current = null; } toast.error(adminErrorMessage(e)); diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx index 502117e49..a41a7ddff 100644 --- a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx @@ -88,6 +88,53 @@ export function adminErrorMessage(e: unknown): string { } } +/** + * Extract the relay's HTTP status from a rejected admin mutation, or `null`. + * + * Native mutation commands reject with a typed `AdminMutationError` + * (`{message, relayStatus}`); the Tauri bridge surfaces it as + * `TauriInvokeError` whose `payload` is that object. `relayStatus` is a number + * only when the relay actually answered — `null`/absent for a transport or + * pre-send failure where no relay verdict exists. + */ +export function adminMutationRelayStatus(e: unknown): number | null { + if (e && typeof e === "object" && "payload" in e) { + const payload = (e as { payload: unknown }).payload; + if (payload && typeof payload === "object" && "relayStatus" in payload) { + const status = (payload as { relayStatus: unknown }).relayStatus; + if (typeof status === "number") return status; + } + } + return null; +} + +/** + * Whether a failed mutation must reuse its idempotency `requestId` on retry. + * + * The id is preserved UNLESS the relay definitively rejected the request before + * committing — a 4xx other than 409. Those (bad action, unauthorized, not + * found) refuse the input pre-commit, so a corrected resubmission is a + * genuinely new command and a fresh id is safe. + * + * Everything else preserves the id so the relay can dedupe against a commit + * that may have landed: + * - 409 — an idempotency claim or in-progress action already exists; + * - 5xx — the relay may have committed before failing; + * - a lost response body (status arrived, outcome unknown); + * - a transport or pre-send failure with no relay answer (`relayStatus` null). + * + * This replaces string-matching `"409"`/`"processing"` on the message — which + * missed the native layer's transport errors (`relay unreachable: …`, `admin + * response stream error`) and cleared the id on exactly the ambiguous + * lost-response failures where reuse is required. + */ +export function preserveRequestIdOnError(e: unknown): boolean { + const status = adminMutationRelayStatus(e); + if (status === null) return true; + if (status === 409) return true; + return status < 400 || status >= 500; +} + // ── Shared UI helpers ───────────────────────────────────────────────────── export function LoadingSpinner() { diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs index e9bdda6da..0b814f255 100644 --- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -100,6 +100,21 @@ toast.error = (msg) => { return 0; }; +// ── Typed native mutation error ────────────────────────────────────────────── +// +// Admin mutation commands reject with a serialized Rust `AdminMutationError` +// (`{message, relayStatus}`, camelCase). The real tauri bridge rejects with +// that plain object and `toTauriError` wraps it into a `TauriInvokeError` whose +// `.message` is the message and `.payload` is the whole object — from which the +// UI reads `relayStatus` to decide idempotency-retry policy. Rejecting with a +// plain object here (NOT an Error) reproduces that wire shape exactly. +// +// `relayStatus` is a number when the relay authoritatively answered, and `null` +// for a transport/pre-send failure where no relay verdict exists. +function mutationReject(message, relayStatus) { + return Promise.reject({ message, relayStatus }); +} + // ── Deferred promise helper ────────────────────────────────────────────────── function deferred() { @@ -2303,11 +2318,13 @@ test("reopen-enforced-copy: a report with an actionId warns enforcement is not r test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same requestId on retry", async () => { // A 409 (report is not reopenable — e.g. it moved to processing) is an - // idempotency-relevant failure: the same requestId must be reused on retry - // so the relay can dedupe. A non-409 failure resets it. + // idempotency-relevant failure: the relay has a claim, so the same requestId + // must be reused on retry to let the relay dedupe. The native command carries + // the relay's HTTP status on the rejected error (`relayStatus: 409`), and the + // UI's preserveRequestIdOnError reads it — no string-matching. // - // Mutation evidence: drop the 409 branch in the catch → requestId resets and - // the two attempts carry different ids, going red. + // Mutation evidence: make preserveRequestIdOnError reset on 409 → the two + // attempts carry different ids and this goes red. const origin = "https://admin.example.com"; const pubkey = "c4".repeat(32); @@ -2341,8 +2358,9 @@ test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same const requestIds = []; setIpcHandler("admin_reopen_report", (args) => { requestIds.push(args?.body?.requestId); - return Promise.reject( - new Error("409 report is not reopenable (current status: processing)"), + return mutationReject( + "admin API error: 409 report is not reopenable (current status: processing)", + 409, ); }); @@ -2387,6 +2405,159 @@ test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same await unmount(); }); +test("reopen-lost-response-preserves-requestId: an ambiguous transport failure reuses the requestId on retry", async () => { + // The bug this fixes: the native layer serializes a timeout/disconnect as + // `relay unreachable: …` and a lost response body as `admin response stream + // error` — neither contains "409"/"processing", so the old string-match + // cleared the requestId and the retry became a brand-new command. The + // concrete harm is a two-operator interleave: A's reopen COMMITS, the + // response is lost; B resolves the now-open report; A's retry with a fresh id + // reopens B's later resolution. Reusing the original id makes the retry hit + // the relay's idempotent path harmlessly. + // + // A lost-response failure carries no relay verdict (`relayStatus: null`), so + // preserveRequestIdOnError must keep the id. Mutation evidence: change the + // null-status branch to reset → the two attempts carry different ids, red. + + const origin = "https://admin.example.com"; + const pubkey = "c5".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c5", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + // Transport failure: no relay answer, so no HTTP status. + return mutationReject("relay unreachable: network error", null); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + // First attempt → lost response. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + // Second attempt → same ambiguous failure; requestId must be identical. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.equal( + requestIds[0], + requestIds[1], + `requestId must be preserved across a lost-response retry; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + +test("reopen-4xx-resets-requestId: a definitive pre-commit rejection uses a fresh requestId", async () => { + // A non-409 4xx (e.g. 400 bad request) is a definitive pre-commit rejection: + // the relay refused the input and committed nothing, so a corrected + // resubmission is a genuinely new command and a fresh requestId is correct. + // This is the ONLY case that resets — the counterpart to the ambiguous + // failures above. + // + // Mutation evidence: make preserveRequestIdOnError preserve on a 400 → the + // two attempts share an id and this goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c6".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c6", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + return mutationReject("admin API error: bad request", 400); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.notEqual( + requestIds[0], + requestIds[1], + `a non-409 4xx must reset the requestId; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + test("cancel-on-failed: a failed action offers Cancel, POSTs {actionId} to admin_cancel_report, and reloads to open", async () => { // Cancel-then-resolve is the only recovery from a failed enforcement. The // block offers Cancel on `status: "failed"`, fences it on the action id, and @@ -3136,16 +3307,15 @@ test("resolve-rejection-surfaces-parsed-message: a rejected resolve toasts the r const humanMessage = "action kick requires the report to have an associated channel"; - // The native command rejects with `admin API error: {envelope}` — exactly the - // shape adminErrorMessage strips down to the envelope's `message`. + // The native command rejects with a typed AdminMutationError: message is + // `admin API error: {envelope}` (the shape adminErrorMessage strips to the + // envelope's `message`) and relayStatus is the relay's 400. const rawError = `admin API error: {"error":{"code":"invalid_action_for_target","message":"${humanMessage}","requestId":"00000000-0000-0000-0000-0000000000e7"}}`; setIpcHandler("admin_list_reports", () => Promise.resolve([openItem])); setIpcHandler("admin_get_report", () => Promise.resolve(openDetail)); setIpcHandler("admin_list_feedback", () => Promise.resolve([])); - setIpcHandler("admin_resolve_report", () => - Promise.reject(new Error(rawError)), - ); + setIpcHandler("admin_resolve_report", () => mutationReject(rawError, 400)); const { container, doRender, unmount } = mountPanel({ origin, pubkey }); await doRender();