mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
feat(desktop): cancel-and-reopen recovery for failed enforcement
Wire the admin console to the relay's cancel route and align the report
detail to the ratified LATERAL semantics.
Cancel replaces the composed client-side retry. A failed enforcement is
always pre-mutation, so it is always cancellable; cancel returns the
report to `open` for a fresh resolution. Composing cancel + resolve would
imply an atomicity the relay does not provide, so the console offers a
single "Cancel & reopen" button on `failed` only — pending/enforcing are
owned by the relay recovery worker — POSTs `{actionId}` to
/reports/{id}/cancel, treats 409 as refresh-detail, and reloads rather
than consuming the response body.
Honest history: the detail LATERAL returns a succeeded action even on a
reopened (open) report — the ban actually ran, and a later reopen does
not un-happen it. The enforcement block now renders whenever an action is
present (live or history) and the resolve form gates on `isOpen` alone, so
a reopened-after-enforcement report shows both the executed-enforcement
history and the resolve form for re-triage. The old `isOpen && !activeAction`
gate wrongly stranded the operator on such a report.
Tighten the wire seam to match the relay's no-skip_serializing_if
serialization: reason/expiresAt/errorMessage are required-nullable, and
resolve/cancel return the `{status, activeAction}` envelope. Feedback
communityId/communityHost are nullable — a purged source community severs
provenance to NULL while retaining the row as operator evidence; severed
rows bucket under a "source community removed" heading and render em-dashes
in detail.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -515,6 +515,33 @@ pub async fn admin_reopen_report(
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}"))
|
||||
}
|
||||
|
||||
/// Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel.
|
||||
///
|
||||
/// Body: `{actionId}`. Cancel is the only recovery path for a pre-mutation
|
||||
/// `failed` action: it returns the report to `open` for a fresh resolution.
|
||||
/// The `actionId` fences the cancel to the failed action the operator observed;
|
||||
/// a mismatch (already cancelled, superseded, or past the mutation point) is a
|
||||
/// 409, which the caller treats as "refresh detail".
|
||||
#[tauri::command]
|
||||
pub async fn admin_cancel_report(
|
||||
origin: String,
|
||||
id: String,
|
||||
body: serde_json::Value,
|
||||
state: tauri::State<'_, crate::app_state::AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let origin = origin::AdminOrigin::parse(&origin)?;
|
||||
let id =
|
||||
uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?;
|
||||
let url = origin.route_url(
|
||||
&routes::AdminRoute::ReportCancel { id },
|
||||
&routes::AdminQuery::default(),
|
||||
);
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// Update feedback status — PATCH /api/admin/v1/feedback/{id}.
|
||||
///
|
||||
/// Body: `{status}` where status ∈ {"new","reviewed","archived"}.
|
||||
|
||||
@@ -53,6 +53,9 @@ pub enum AdminRoute {
|
||||
ReportReopen {
|
||||
id: uuid::Uuid,
|
||||
},
|
||||
ReportCancel {
|
||||
id: uuid::Uuid,
|
||||
},
|
||||
FeedbackList,
|
||||
FeedbackDetail {
|
||||
id: uuid::Uuid,
|
||||
@@ -105,6 +108,7 @@ impl AdminRoute {
|
||||
AdminRoute::ReportDetail { id } => format!("/reports/{id}"),
|
||||
AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"),
|
||||
AdminRoute::ReportReopen { id } => format!("/reports/{id}/reopen"),
|
||||
AdminRoute::ReportCancel { id } => format!("/reports/{id}/cancel"),
|
||||
AdminRoute::FeedbackList => "/feedback".to_string(),
|
||||
AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"),
|
||||
AdminRoute::FeedbackAttachment { id, sha256 } => {
|
||||
@@ -259,6 +263,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_cancel_path() {
|
||||
let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap();
|
||||
assert_eq!(
|
||||
AdminRoute::ReportCancel { id }.path(),
|
||||
"/reports/00000000-0000-0000-0000-000000000004/cancel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_attachment_path() {
|
||||
let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
|
||||
|
||||
@@ -842,6 +842,7 @@ pub fn run() {
|
||||
admin_fetch_feedback_attachment,
|
||||
admin_resolve_report,
|
||||
admin_reopen_report,
|
||||
admin_cancel_report,
|
||||
admin_patch_feedback,
|
||||
admin_list_operators,
|
||||
admin_put_operator,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
getAdminReport,
|
||||
listAdminReports,
|
||||
cancelAdminReport,
|
||||
reopenAdminReport,
|
||||
resolveAdminReport,
|
||||
type AdminPrincipalRole,
|
||||
@@ -152,12 +153,20 @@ function actionVariant(
|
||||
|
||||
/**
|
||||
* Inline enforcement-state block shown on `processing` reports and after a
|
||||
* failed enforcement action. Shows the action record's state and offers
|
||||
* retry/cancel where appropriate.
|
||||
* failed enforcement action. Shows the action record's state and offers cancel
|
||||
* on a `failed` action.
|
||||
*
|
||||
* Cancel is only offered when the server permits it (pre-mutation failures).
|
||||
* A rejected cancel is treated as authoritative — the server knows whether
|
||||
* the mutation has landed.
|
||||
* A `failed` action is always pre-mutation (the relay only records `failed`
|
||||
* before the enforcement side effect lands), so it is always cancellable.
|
||||
* Cancel is the only recovery path: it returns the report to `open` for a
|
||||
* fresh resolution. There is no client-side "retry" — composing cancel + a new
|
||||
* resolve would imply an atomicity the relay does not provide, leaving a window
|
||||
* where the report is open with no explanation if the second call is lost.
|
||||
*
|
||||
* `pending`/`enforcing` actions are NOT cancellable over HTTP — the relay's
|
||||
* recovery worker owns their convergence — so no button is offered there.
|
||||
* A rejected cancel (409) is authoritative: the action already advanced or
|
||||
* someone else cancelled it, so reload detail rather than retrying.
|
||||
*/
|
||||
function EnforcementStateBlock({
|
||||
activeAction,
|
||||
@@ -184,45 +193,26 @@ function EnforcementStateBlock({
|
||||
cancelled: "Enforcement cancelled",
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
setError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
// Retry reuses the same requestId so the server returns the existing
|
||||
// action record rather than creating a new claim.
|
||||
await resolveAdminReport(origin, reportId, {
|
||||
action: activeAction.action,
|
||||
requestId: activeAction.requestId,
|
||||
reason: activeAction.reason ?? undefined,
|
||||
});
|
||||
toast.success("Enforcement retried");
|
||||
onActionComplete();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
// Cancel via the same resolve endpoint with action="dismiss".
|
||||
// The server rejects cancel if mutation has already landed.
|
||||
await resolveAdminReport(origin, reportId, {
|
||||
action: "dismiss",
|
||||
requestId: crypto.randomUUID(),
|
||||
reason: "cancelled",
|
||||
// Fence the cancel to the exact failed action the operator observed. On
|
||||
// success the report returns to `open`; the detail reload then serves
|
||||
// `activeAction: null` and re-exposes the resolve form for a fresh attempt.
|
||||
await cancelAdminReport(origin, reportId, {
|
||||
actionId: activeAction.id,
|
||||
});
|
||||
toast.success("Enforcement cancelled");
|
||||
toast.success("Enforcement cancelled — report reopened");
|
||||
onActionComplete();
|
||||
} catch (e) {
|
||||
// A rejected cancel is authoritative — the enforcement may have landed.
|
||||
// Surface the error but do not retry.
|
||||
// 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)}`,
|
||||
);
|
||||
onActionComplete();
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
@@ -244,33 +234,26 @@ function EnforcementStateBlock({
|
||||
{activeAction.action}
|
||||
</span>
|
||||
</div>
|
||||
{activeAction.errorMessage && (
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{activeAction.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
{actionStatus === "failed" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
data-testid="enforcement-retry-btn"
|
||||
disabled={isWorking}
|
||||
onClick={() => void handleRetry()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isWorking ? (
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Retry"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="enforcement-cancel-btn"
|
||||
disabled={isWorking}
|
||||
onClick={() => void handleCancel()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="enforcement-cancel-btn"
|
||||
disabled={isWorking}
|
||||
onClick={() => void handleCancel()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isWorking ? (
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Cancel & reopen"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
@@ -684,7 +667,6 @@ function ReportDetail({
|
||||
);
|
||||
|
||||
const data = detailState.status === "ok" ? detailState.data : null;
|
||||
const isProcessing = data?.status === "processing";
|
||||
const isOpen = data?.status === "open";
|
||||
const isReopenable =
|
||||
data?.status === "resolved" ||
|
||||
@@ -709,21 +691,28 @@ function ReportDetail({
|
||||
{detailState.status === "ok" && (
|
||||
<>
|
||||
<ReportFields data={detailState.data} />
|
||||
{/* Enforcement state: present on processing reports or failed actions */}
|
||||
{activeAction &&
|
||||
(isProcessing ||
|
||||
activeAction.status === "failed" ||
|
||||
activeAction.status === "pending" ||
|
||||
activeAction.status === "enforcing") && (
|
||||
<EnforcementStateBlock
|
||||
activeAction={activeAction}
|
||||
origin={origin}
|
||||
reportId={reportId}
|
||||
onActionComplete={() => setResolveGen((g) => g + 1)}
|
||||
/>
|
||||
)}
|
||||
{/* Resolve form: only for open (non-processing) reports */}
|
||||
{isOpen && !activeAction && (
|
||||
{/* Enforcement state / history. The detail LATERAL returns an action
|
||||
whenever one governs the report: a live action (pending/enforcing)
|
||||
or a cancellable failed action while processing, or a succeeded
|
||||
action as executed-enforcement history on a terminal or reopened
|
||||
report (honest history — a later dismissal/reopen does not
|
||||
un-happen the ban that ran). Cancel is offered only on failed,
|
||||
inside the block. A cancelled action never reaches this read. */}
|
||||
{activeAction && (
|
||||
<EnforcementStateBlock
|
||||
activeAction={activeAction}
|
||||
origin={origin}
|
||||
reportId={reportId}
|
||||
onActionComplete={() => setResolveGen((g) => g + 1)}
|
||||
/>
|
||||
)}
|
||||
{/* Resolve form: shown for open reports. A reopened-after-enforcement
|
||||
report is open yet carries a succeeded activeAction (history);
|
||||
the form must still show so the operator can re-triage — the
|
||||
enforcement block above renders that history alongside it. Only a
|
||||
live/failed action keeps the report `processing` (not open), so
|
||||
`isOpen` alone never surfaces the form on an in-flight action. */}
|
||||
{isOpen && (
|
||||
<ResolveReportForm
|
||||
report={detailState.data}
|
||||
origin={origin}
|
||||
|
||||
@@ -186,6 +186,10 @@ export type CommunityGroup<T> = {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
/** React key + heading for rows whose source community has been purged. */
|
||||
const SEVERED_COMMUNITY_KEY = "__severed__";
|
||||
const SEVERED_COMMUNITY_HOST = "(source community removed)";
|
||||
|
||||
/**
|
||||
* Group deployment-wide rows by community, preserving each community's
|
||||
* first-seen order and the server's row order within it.
|
||||
@@ -194,21 +198,30 @@ export type CommunityGroup<T> = {
|
||||
* deployment; operators triage per community, so rows are bucketed by
|
||||
* `communityId` (stable) and labelled by `communityHost` (display). A blank
|
||||
* host falls back to the id so a group is never headed by an empty string.
|
||||
*
|
||||
* Feedback whose source community was purged carries a `null` `communityId`
|
||||
* (tenant provenance severed, the row retained as operator evidence). Those
|
||||
* rows bucket into a single "source community removed" group so a null key
|
||||
* never collapses distinct rows or heads a group with an empty string.
|
||||
*/
|
||||
export function groupByCommunity<
|
||||
T extends { communityId: string; communityHost: string },
|
||||
T extends { communityId: string | null; communityHost: string | null },
|
||||
>(items: T[]): CommunityGroup<T>[] {
|
||||
const groups: CommunityGroup<T>[] = [];
|
||||
const byId = new Map<string, CommunityGroup<T>>();
|
||||
for (const item of items) {
|
||||
let group = byId.get(item.communityId);
|
||||
const key = item.communityId ?? SEVERED_COMMUNITY_KEY;
|
||||
let group = byId.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
communityId: item.communityId,
|
||||
communityHost: item.communityHost || item.communityId,
|
||||
communityId: key,
|
||||
communityHost:
|
||||
item.communityId == null
|
||||
? SEVERED_COMMUNITY_HOST
|
||||
: item.communityHost || item.communityId,
|
||||
items: [],
|
||||
};
|
||||
byId.set(item.communityId, group);
|
||||
byId.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(item);
|
||||
@@ -224,7 +237,7 @@ export function groupByCommunity<
|
||||
* entry — the caller owns row markup so navigation/testids are unchanged.
|
||||
*/
|
||||
export function CommunityGroupedList<
|
||||
T extends { communityId: string; communityHost: string },
|
||||
T extends { communityId: string | null; communityHost: string | null },
|
||||
>({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) {
|
||||
const groups = groupByCommunity(items);
|
||||
if (groups.length <= 1) {
|
||||
|
||||
@@ -2380,3 +2380,391 @@ test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same
|
||||
|
||||
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
|
||||
// on success the report returns to `open` — the detail reload then serves
|
||||
// activeAction: null and re-exposes the resolve form for a fresh attempt.
|
||||
//
|
||||
// Mutation evidence: revert handleCancel to the old resolve-with-dismiss
|
||||
// masquerade → admin_cancel_report is never called and cancelArgs stays null.
|
||||
// Restore the `!activeAction` gate on the resolve form → the reopened report
|
||||
// still carries no action here, so this test isolates the cancel wiring.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "e5".repeat(32);
|
||||
|
||||
const base = {
|
||||
id: "00000000-0000-0000-0000-0000000000e5",
|
||||
communityId: "00000000-0000-0000-0000-000000000002",
|
||||
communityHost: "relay.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const actionId = "00000000-0000-0000-0000-0000000000f1";
|
||||
const failedDetail = {
|
||||
...base,
|
||||
status: "processing",
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
activeAction: {
|
||||
id: actionId,
|
||||
requestId: "00000000-0000-0000-0000-0000000000f2",
|
||||
actorPubkey:
|
||||
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||
actorRole: "operator",
|
||||
action: "ban",
|
||||
status: "failed",
|
||||
reason: null,
|
||||
expiresAt: null,
|
||||
errorMessage: "adapter timeout",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
updatedAt: "2024-06-01T12:00:05Z",
|
||||
},
|
||||
message: null,
|
||||
};
|
||||
const openDetail = {
|
||||
...base,
|
||||
status: "open",
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
activeAction: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () =>
|
||||
Promise.resolve([{ ...base, status: "processing" }]),
|
||||
);
|
||||
let detailCalls = 0;
|
||||
setIpcHandler("admin_get_report", () => {
|
||||
detailCalls += 1;
|
||||
return Promise.resolve(detailCalls === 1 ? failedDetail : openDetail);
|
||||
});
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
let cancelArgs = null;
|
||||
setIpcHandler("admin_cancel_report", (args) => {
|
||||
cancelArgs = args;
|
||||
return Promise.resolve({
|
||||
status: "open",
|
||||
activeAction: { ...failedDetail.activeAction, status: "cancelled" },
|
||||
});
|
||||
});
|
||||
// The dismiss-masquerade path must be gone: resolve must never be called.
|
||||
let resolveCalled = false;
|
||||
setIpcHandler("admin_resolve_report", () => {
|
||||
resolveCalled = true;
|
||||
return Promise.reject(new Error("resolve must not be called by cancel"));
|
||||
});
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
// The failed action surfaces the error message and a single Cancel button.
|
||||
const block = container.querySelector(
|
||||
"[data-testid='enforcement-state-block']",
|
||||
);
|
||||
assert.ok(block, "enforcement-state-block must render for a failed action");
|
||||
assert.ok(
|
||||
(block.textContent ?? "").includes("adapter timeout"),
|
||||
`the failure errorMessage must render; got: ${block.textContent}`,
|
||||
);
|
||||
assert.equal(
|
||||
container.querySelector("[data-testid='enforcement-retry-btn']"),
|
||||
null,
|
||||
"the composed-retry button must be gone (Cancel-only on failed)",
|
||||
);
|
||||
const cancelBtn = container.querySelector(
|
||||
"[data-testid='enforcement-cancel-btn']",
|
||||
);
|
||||
assert.ok(cancelBtn, "the Cancel button must render on a failed action");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelBtn);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
assert.ok(cancelArgs, "admin_cancel_report must be invoked");
|
||||
assert.equal(cancelArgs.origin, origin, "origin must be forwarded");
|
||||
assert.equal(cancelArgs.id, base.id, "report id must be forwarded");
|
||||
assert.equal(
|
||||
cancelArgs.body?.actionId,
|
||||
actionId,
|
||||
"cancel must be fenced on the observed action id",
|
||||
);
|
||||
assert.equal(
|
||||
resolveCalled,
|
||||
false,
|
||||
"cancel must NOT go through the resolve endpoint (no dismiss masquerade)",
|
||||
);
|
||||
assert.ok(
|
||||
capturedToasts.some((m) => m.toLowerCase().includes("cancel")),
|
||||
`a cancel success toast must fire; got: ${JSON.stringify(capturedToasts)}`,
|
||||
);
|
||||
// Detail reloaded; the now-open report shows the resolve form for re-triage.
|
||||
assert.ok(detailCalls >= 2, "detail must reload after cancel");
|
||||
assert.ok(
|
||||
container.querySelector("[data-testid='resolve-report-form']"),
|
||||
"after cancel the reopened report must show the resolve form",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("no-cancel-on-in-flight: pending and enforcing actions offer no cancel button", async () => {
|
||||
// Only a pre-mutation `failed` action is cancellable over HTTP. A stuck
|
||||
// `pending`/`enforcing` action is owned by the relay's recovery worker; the
|
||||
// UI must not offer a button that 409s by design.
|
||||
//
|
||||
// Mutation evidence: change the button gate from `=== "failed"` to include
|
||||
// enforcing → the assertion that no cancel button renders goes red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "e6".repeat(32);
|
||||
|
||||
const base = {
|
||||
id: "00000000-0000-0000-0000-0000000000e6",
|
||||
communityId: "00000000-0000-0000-0000-000000000002",
|
||||
communityHost: "relay.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const enforcingDetail = {
|
||||
...base,
|
||||
status: "processing",
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
activeAction: {
|
||||
id: "00000000-0000-0000-0000-0000000000f3",
|
||||
requestId: "00000000-0000-0000-0000-0000000000f4",
|
||||
actorPubkey:
|
||||
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||
actorRole: "operator",
|
||||
action: "ban",
|
||||
status: "enforcing",
|
||||
reason: null,
|
||||
expiresAt: null,
|
||||
errorMessage: null,
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
updatedAt: "2024-06-01T12:00:01Z",
|
||||
},
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () =>
|
||||
Promise.resolve([{ ...base, status: "processing" }]),
|
||||
);
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(enforcingDetail));
|
||||
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='enforcement-state-block']"),
|
||||
"enforcement-state-block must render for an enforcing action",
|
||||
);
|
||||
assert.equal(
|
||||
container.querySelector("[data-testid='enforcement-cancel-btn']"),
|
||||
null,
|
||||
"no cancel button on an in-flight (enforcing) action",
|
||||
);
|
||||
// And the resolve form must stay suppressed on a processing report.
|
||||
assert.equal(
|
||||
container.querySelector("[data-testid='resolve-report-form']"),
|
||||
null,
|
||||
"resolve form must not render on a processing report",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("reopened-after-enforcement: an open report carrying a succeeded action shows both history and the resolve form", async () => {
|
||||
// Honest history: a report enforced then reopened is `open` yet the detail
|
||||
// LATERAL still returns the succeeded action (the ban actually ran — a later
|
||||
// reopen does not un-happen it). The UI must render that action as executed
|
||||
// history AND still offer the resolve form, because the report is open for
|
||||
// re-triage. Cancel must NOT appear — cancel is failed-only.
|
||||
//
|
||||
// Mutation evidence: restore the `isOpen && !activeAction` gate → the resolve
|
||||
// form vanishes on this report and the operator is stranded, going red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "e7".repeat(32);
|
||||
|
||||
const reopenedDetail = {
|
||||
id: "00000000-0000-0000-0000-0000000000e7",
|
||||
communityId: "00000000-0000-0000-0000-000000000002",
|
||||
communityHost: "relay.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
activeAction: {
|
||||
id: "00000000-0000-0000-0000-0000000000f5",
|
||||
requestId: "00000000-0000-0000-0000-0000000000f6",
|
||||
actorPubkey:
|
||||
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||
actorRole: "operator",
|
||||
action: "ban",
|
||||
status: "succeeded",
|
||||
reason: "confirmed spam",
|
||||
expiresAt: null,
|
||||
errorMessage: null,
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
updatedAt: "2024-06-01T12:00:03Z",
|
||||
},
|
||||
message: null,
|
||||
createdAt: "2024-06-01T11:00:00Z",
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () =>
|
||||
Promise.resolve([{ ...reopenedDetail, activeAction: undefined }]),
|
||||
);
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(reopenedDetail));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
// Executed-enforcement history renders.
|
||||
const block = container.querySelector(
|
||||
"[data-testid='enforcement-state-block']",
|
||||
);
|
||||
assert.ok(block, "the succeeded action must render as enforcement history");
|
||||
assert.ok(
|
||||
(block.textContent ?? "").toLowerCase().includes("succeeded"),
|
||||
`history must show the succeeded state; got: ${block.textContent}`,
|
||||
);
|
||||
// Cancel is failed-only — never on a succeeded action.
|
||||
assert.equal(
|
||||
container.querySelector("[data-testid='enforcement-cancel-btn']"),
|
||||
null,
|
||||
"no cancel button on a succeeded action",
|
||||
);
|
||||
// The resolve form must still show — the report is open for re-triage.
|
||||
assert.ok(
|
||||
container.querySelector("[data-testid='resolve-report-form']"),
|
||||
"an open reopened-after-enforcement report must still show the resolve form",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("feedback-severed-community: a purged-source feedback row renders in list and detail without its community", async () => {
|
||||
// Item 5 (desktop): feedback whose source community was purged carries a
|
||||
// null communityId/communityHost (tenant provenance severed, row retained as
|
||||
// operator evidence). The list must still render it (grouped under a
|
||||
// "source community removed" bucket) and the detail must show em-dashes for
|
||||
// the absent community fields — never crash on the null.
|
||||
//
|
||||
// Mutation evidence: narrow AdminFeedbackDto.communityId back to `string` →
|
||||
// typecheck breaks; restore the `communityId: string` grouping constraint →
|
||||
// the null key throws in groupByCommunity.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "e8".repeat(32);
|
||||
|
||||
const severedSummary = {
|
||||
id: "00000000-0000-0000-0000-0000000000e8",
|
||||
communityId: null,
|
||||
communityHost: null,
|
||||
submitterPubkey: "sub-severed",
|
||||
category: "bug",
|
||||
bodySummary: "Feedback from a since-purged community",
|
||||
status: "new",
|
||||
receivedAt: "2024-06-01T09:00:00Z",
|
||||
};
|
||||
const severedDetail = {
|
||||
id: severedSummary.id,
|
||||
communityId: null,
|
||||
communityHost: null,
|
||||
eventId: "sevevent",
|
||||
submitterPubkey: severedSummary.submitterPubkey,
|
||||
category: "bug",
|
||||
body: "Feedback from a since-purged community — full body",
|
||||
status: "new",
|
||||
tags: [],
|
||||
eventCreatedAt: "2024-06-01T09:00:00Z",
|
||||
receivedAt: "2024-06-01T09:00:00Z",
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([severedSummary]));
|
||||
setIpcHandler("admin_get_feedback", () => Promise.resolve(severedDetail));
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
|
||||
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(30);
|
||||
|
||||
// The severed row still renders in the list (did not throw / vanish).
|
||||
const listRow = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) =>
|
||||
!(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") &&
|
||||
btn.textContent?.includes("since-purged community"),
|
||||
);
|
||||
assert.ok(listRow, "the severed feedback row must render in the list");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(listRow);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(30);
|
||||
|
||||
// Detail renders; the community fields show the em-dash placeholder.
|
||||
const fields = container.querySelector(
|
||||
"[data-testid='feedback-detail-fields']",
|
||||
);
|
||||
assert.ok(fields, "feedback detail must render for a severed row");
|
||||
assert.ok(
|
||||
(fields.textContent ?? "").includes("—"),
|
||||
`absent community fields must render as em-dash; got: ${fields.textContent}`,
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
@@ -161,8 +161,13 @@ export type AdminReportDetailDto = AdminReportDto & {
|
||||
/** Deployment-global product feedback entry. */
|
||||
export type AdminFeedbackDto = {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
/**
|
||||
* Source community. Both are `null` once the source community has been
|
||||
* purged: feedback is deployment-global operator evidence whose
|
||||
* `communityId` is severed to NULL on tenant purge, not cascade-deleted.
|
||||
*/
|
||||
communityId: string | null;
|
||||
communityHost: string | null;
|
||||
eventId: string;
|
||||
submitterPubkey: string;
|
||||
category?: string | null;
|
||||
@@ -185,8 +190,9 @@ export type AdminFeedbackDto = {
|
||||
*/
|
||||
export type AdminFeedbackSummaryDto = {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
/** Source community — `null` on a severed (purged-source) row. */
|
||||
communityId: string | null;
|
||||
communityHost: string | null;
|
||||
submitterPubkey: string;
|
||||
category?: string | null;
|
||||
bodySummary: string;
|
||||
@@ -291,13 +297,27 @@ export type AdminActionRecordDto = {
|
||||
actorRole: AdminPrincipalRole;
|
||||
action: AdminReportAction;
|
||||
status: "pending" | "enforcing" | "succeeded" | "failed" | "cancelled";
|
||||
reason?: string | null;
|
||||
expiresAt?: string | null;
|
||||
errorMessage?: string | null;
|
||||
reason: string | null;
|
||||
expiresAt: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uniform envelope returned by resolve and cancel: the report's new terminal
|
||||
* status plus the governing action record. `activeAction` is null for
|
||||
* decision-only resolutions (`dismiss`/`escalate`, which create no record).
|
||||
*
|
||||
* Both endpoints re-read the report so this shape matches a subsequent
|
||||
* `GET /reports/{id}` — the console reloads detail after a mutation rather than
|
||||
* consuming this body, so it is a wire contract, not a render source.
|
||||
*/
|
||||
export type AdminReportResolution = {
|
||||
status: string;
|
||||
activeAction: AdminActionRecordDto | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a report — POST /api/admin/v1/reports/{id}/resolve.
|
||||
*
|
||||
@@ -309,8 +329,43 @@ export async function resolveAdminReport(
|
||||
origin: string,
|
||||
id: string,
|
||||
body: AdminResolveReportBody,
|
||||
): Promise<AdminActionRecordDto> {
|
||||
return invokeTauri<AdminActionRecordDto>("admin_resolve_report", {
|
||||
): Promise<AdminReportResolution> {
|
||||
return invokeTauri<AdminReportResolution>("admin_resolve_report", {
|
||||
origin,
|
||||
id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for POST /api/admin/v1/reports/{id}/cancel.
|
||||
*
|
||||
* `actionId` fences the cancel to exactly the failed action the operator
|
||||
* observed. A mismatch — already cancelled, superseded by a newer claim, or
|
||||
* past the mutation point — resolves to 409.
|
||||
*/
|
||||
export type AdminCancelReportBody = {
|
||||
actionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel.
|
||||
*
|
||||
* The only recovery path for a `failed` action: it returns the report to
|
||||
* `open` for a fresh resolution attempt (there is no composed client-side
|
||||
* retry — that would imply an atomicity the relay does not provide). A `409`
|
||||
* means the action is no longer cancellable; treat it as "refresh detail" —
|
||||
* someone else likely cancelled it or it advanced past the mutation point.
|
||||
*
|
||||
* The response embeds the just-cancelled action as a last look; a subsequent
|
||||
* detail read serves `activeAction: null`.
|
||||
*/
|
||||
export async function cancelAdminReport(
|
||||
origin: string,
|
||||
id: string,
|
||||
body: AdminCancelReportBody,
|
||||
): Promise<AdminReportResolution> {
|
||||
return invokeTauri<AdminReportResolution>("admin_cancel_report", {
|
||||
origin,
|
||||
id,
|
||||
body,
|
||||
|
||||
Reference in New Issue
Block a user