mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(failure): retry and decrypt-and-retry for recorded failures
The client half of retry, on top of the slot/resolve PR. useToolOperation stashes what a failed run needs to run again (endpoint, parameters, file ids - passwords stripped at any depth, 25 records, oldest evicted) in its own IndexedDB database. Retry on an editor failure opens the failed tool with the document selected. Decrypt and retry opens the app's unlock modal, re-runs the stashed operation - or, for a policy failure, unlocks via /security/remove-password and re-runs the stored policy - adopts the result by versioning the encrypted original in place, and reports the row resolved. A policy re-run is registered with the run store so it polls to terminal, honours outputMode, and rejoins the rest of the upload chain; an upload's chain now also holds back until its unlock prompt is answered. hasLocalFile moves into the retry stash module, which replaces localFilePresence.
This commit is contained in:
@@ -5506,13 +5506,19 @@ count = "{{remaining}} of {{total}}"
|
||||
label = "Free credits"
|
||||
|
||||
[notifications]
|
||||
adoptFailed = "The document was unlocked but could not be opened here. Try the tool directly."
|
||||
empty = "You're all caught up."
|
||||
handoffUnavailable = "This browser will not let the processor pass the document to the editor. Open it from the editor instead."
|
||||
noDocumentLinked = "This failure is not linked to a specific document, so it cannot be opened or retried here."
|
||||
notOnThisDevice = "This document is not on this device, so it cannot be opened or retried here."
|
||||
occurrences = "{{count}} times"
|
||||
open = "Notifications"
|
||||
rerunRejected = "The policy could not be run again just now. Try again in a moment."
|
||||
rerunUndelivered = "The policy re-run started, but its result cannot be delivered here, so this failure stays open."
|
||||
retryUnavailable = "This document can no longer be retried from this browser."
|
||||
title = "Notifications"
|
||||
unlockedNotRerun = "The document was unlocked and opened here, but the policy could not be run on it again."
|
||||
unlockedRerunUndelivered = "The document was unlocked and the policy re-run started, but its result cannot be delivered here, so this failure stays open."
|
||||
unread = "Unread"
|
||||
|
||||
[notifications.action]
|
||||
|
||||
@@ -33,20 +33,23 @@ vi.mock("@app/services/notifications", () => ({
|
||||
// IndexedDB, which jsdom has none of. Answered here so availability is a fact of the test.
|
||||
const h = vi.hoisted(() => ({
|
||||
hasLocalFile: true,
|
||||
retryPayload: { operation: "removePassword" } as unknown,
|
||||
// This build has the notifications API, except in the one test about the build that does not.
|
||||
notificationsAvailable: true,
|
||||
specs: {} as Record<
|
||||
string,
|
||||
{
|
||||
available: (context: unknown) => boolean;
|
||||
run: (context: unknown) => unknown;
|
||||
run: (context: unknown, password?: string) => unknown;
|
||||
needsPassword?: boolean;
|
||||
closesPanel?: boolean;
|
||||
}
|
||||
>,
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/localFilePresence", () => ({
|
||||
vi.mock("@app/services/notificationRetry", () => ({
|
||||
hasLocalFile: () => Promise.resolve(h.hasLocalFile),
|
||||
loadRetryPayload: () => Promise.resolve(h.retryPayload),
|
||||
}));
|
||||
|
||||
vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({
|
||||
@@ -146,6 +149,7 @@ describe("NotificationBell", () => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
h.hasLocalFile = true;
|
||||
h.retryPayload = { operation: "removePassword" };
|
||||
h.notificationsAvailable = true;
|
||||
h.specs = {};
|
||||
});
|
||||
@@ -543,16 +547,57 @@ describe("NotificationBell", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a failed action in the row instead of leaving the user guessing", async () => {
|
||||
it("asks for the password in the unlock modal before it retries", async () => {
|
||||
const run = vi.fn().mockResolvedValue({ ok: true });
|
||||
h.specs = {
|
||||
VIEW_FILE: {
|
||||
DECRYPT_AND_RETRY: {
|
||||
available: () => true,
|
||||
run: () => Promise.resolve({ ok: false, message: "Could not open" }),
|
||||
run,
|
||||
needsPassword: true,
|
||||
closesPanel: true,
|
||||
},
|
||||
};
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Password-protected document", {
|
||||
actions: [offer("VIEW_FILE")],
|
||||
actions: [offer("DECRYPT_AND_RETRY", "RESOLUTION")],
|
||||
}),
|
||||
]);
|
||||
render(<NotificationBell />);
|
||||
await openPanel();
|
||||
|
||||
// The click opens the app's unlock modal rather than running anything.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "DECRYPT_AND_RETRY: Password-protected document",
|
||||
}),
|
||||
);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
|
||||
const field = await screen.findByLabelText("PDF password");
|
||||
fireEvent.change(field, { target: { value: "hunter2" } });
|
||||
// The modal's confirm carries the action's own wording, not a generic "unlock".
|
||||
fireEvent.click(screen.getByRole("button", { name: "DECRYPT_AND_RETRY" }));
|
||||
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(1));
|
||||
expect(run.mock.calls[0][1]).toBe("hunter2");
|
||||
// Resolved server-side, so the list is re-read and the panel gets out of the way.
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Password-protected document")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a failed unlock in the modal instead of leaving the user guessing", async () => {
|
||||
h.specs = {
|
||||
DECRYPT_AND_RETRY: {
|
||||
available: () => true,
|
||||
run: () => Promise.resolve({ ok: false, message: "Wrong password" }),
|
||||
needsPassword: true,
|
||||
},
|
||||
};
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Password-protected document", {
|
||||
actions: [offer("DECRYPT_AND_RETRY", "RESOLUTION")],
|
||||
}),
|
||||
]);
|
||||
render(<NotificationBell />);
|
||||
@@ -560,16 +605,19 @@ describe("NotificationBell", () => {
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "VIEW_FILE: Password-protected document",
|
||||
name: "DECRYPT_AND_RETRY: Password-protected document",
|
||||
}),
|
||||
);
|
||||
const field = await screen.findByLabelText("PDF password");
|
||||
fireEvent.change(field, { target: { value: "nope" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "DECRYPT_AND_RETRY" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
"textContent",
|
||||
"Could not open",
|
||||
"Wrong password",
|
||||
);
|
||||
// Still on screen, so the row remains actionable.
|
||||
expect(screen.getByText("Password-protected document")).toBeTruthy();
|
||||
// The prompt stays up, so the next attempt is one keystroke rather than a re-open.
|
||||
expect(screen.getByLabelText("PDF password")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reads the kind's own words rather than the raw failure", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BellIcon, Button } from "@app/ui";
|
||||
import { useNotifications } from "@app/hooks/useNotifications";
|
||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||
import { NotificationPanel } from "@app/components/notifications/NotificationPanel";
|
||||
import { useNotificationPasswordPrompt } from "@app/components/notifications/useNotificationPasswordPrompt";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import "@app/components/notifications/NotificationBell.css";
|
||||
|
||||
@@ -25,6 +26,9 @@ function MountedNotificationBell() {
|
||||
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
|
||||
null,
|
||||
);
|
||||
const { requestPassword, promptModal } = useNotificationPasswordPrompt(() =>
|
||||
setOpen(false),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -69,9 +73,12 @@ function MountedNotificationBell() {
|
||||
<NotificationPanel
|
||||
onClose={() => setOpen(false)}
|
||||
registry={registry}
|
||||
onRequestPassword={requestPassword}
|
||||
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isResolvableHere } from "@app/hooks/useNotifications";
|
||||
import type { NotificationDocumentState } from "@app/hooks/useNotifications";
|
||||
import type {
|
||||
ClientActionRegistry,
|
||||
ClientActionSpec,
|
||||
NotificationActionContext,
|
||||
} from "@app/components/notifications/notificationActions";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
@@ -16,6 +17,15 @@ import type {
|
||||
NotificationActionOffer,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
/** An action that asked for a password, with everything running it needs. */
|
||||
export interface PasswordPrompt {
|
||||
offer: NotificationActionOffer;
|
||||
spec: ClientActionSpec;
|
||||
context: NotificationActionContext;
|
||||
/** The row's title, so the prompt can say which failure it is unlocking for. */
|
||||
rowTitle: string;
|
||||
}
|
||||
|
||||
/** The kind's own sentence, sharing the portal's copy. */
|
||||
function summaryKeyOf(titleKey: string): string {
|
||||
return titleKey.replace(/\.title$/, ".description");
|
||||
@@ -59,6 +69,8 @@ interface NotificationItemProps {
|
||||
documentState: NotificationDocumentState;
|
||||
registry: ClientActionRegistry;
|
||||
onDismissPanel: () => void;
|
||||
/** Hand a password-collecting action to the panel, which owns the prompt. */
|
||||
onRequestPassword: (prompt: PasswordPrompt) => void;
|
||||
}
|
||||
|
||||
/** Its own component because the last attempt's message and the copy state are per-row. */
|
||||
@@ -68,6 +80,7 @@ export function NotificationItem({
|
||||
documentState,
|
||||
registry,
|
||||
onDismissPanel,
|
||||
onRequestPassword,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -78,6 +91,7 @@ export function NotificationItem({
|
||||
const context: NotificationActionContext = {
|
||||
notification,
|
||||
hasLocalFile: documentState.hasLocalFile,
|
||||
retryPayload: documentState.retryPayload,
|
||||
};
|
||||
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
@@ -101,6 +115,11 @@ export function NotificationItem({
|
||||
|
||||
const spec = registry[offer.id];
|
||||
if (!spec) return;
|
||||
// The panel owns the prompt, and runs the action from there.
|
||||
if (spec.needsPassword) {
|
||||
onRequestPassword({ offer, spec, context, rowTitle: title });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(offer.id);
|
||||
const outcome = await spec.run(context);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { useNotifications } from "@app/hooks/useNotifications";
|
||||
import type { ClientActionRegistry } from "@app/components/notifications/notificationActions";
|
||||
import { NotificationItem } from "@app/components/notifications/NotificationItem";
|
||||
import {
|
||||
NotificationItem,
|
||||
type PasswordPrompt,
|
||||
} from "@app/components/notifications/NotificationItem";
|
||||
import "@app/components/notifications/NotificationBell.css";
|
||||
|
||||
/** Named so a trigger in another tree can point at it with aria-controls. */
|
||||
@@ -16,6 +19,8 @@ export interface NotificationPanelProps {
|
||||
registry: ClientActionRegistry;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
/** Hand a password-collecting action up to the host, which outlives this panel. */
|
||||
onRequestPassword: (prompt: PasswordPrompt) => void;
|
||||
}
|
||||
|
||||
/** Mounted only while open, since mounting is what marks everything read. */
|
||||
@@ -25,6 +30,7 @@ export function NotificationPanel({
|
||||
id,
|
||||
style,
|
||||
className,
|
||||
onRequestPassword,
|
||||
}: NotificationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||
@@ -129,6 +135,7 @@ export function NotificationPanel({
|
||||
documentState={documentStateFor(notification)}
|
||||
registry={registry}
|
||||
onDismissPanel={onClose}
|
||||
onRequestPassword={onRequestPassword}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppNotification } from "@app/services/notifications";
|
||||
import type { RetryPayload } from "@app/services/notificationRetry";
|
||||
|
||||
/**
|
||||
* Keyed by action rather than by row, because the server decides what a kind offers: adding a kind is
|
||||
@@ -9,6 +10,8 @@ export interface NotificationActionContext {
|
||||
notification: AppNotification;
|
||||
/** Whether the document is still in this browser, which is what most actions hinge on. */
|
||||
hasLocalFile: boolean;
|
||||
/** What the failed operation was, when this browser stashed it. */
|
||||
retryPayload: RetryPayload | null;
|
||||
}
|
||||
|
||||
/** `void` means it did what it said; a failed outcome carries the message the row shows. */
|
||||
@@ -20,9 +23,13 @@ export interface ClientActionOutcome {
|
||||
export interface ClientActionSpec {
|
||||
/** Asked per row, never during a request. */
|
||||
available(context: NotificationActionContext): boolean;
|
||||
/** `password` is only ever passed for a spec that asked for one. May answer synchronously. */
|
||||
run(
|
||||
context: NotificationActionContext,
|
||||
password?: string,
|
||||
): ClientActionOutcome | void | Promise<ClientActionOutcome | void>;
|
||||
/** Collect a password before running. Never stored, never logged. */
|
||||
needsPassword?: boolean;
|
||||
/** Whether the panel should get out of the way, the destination being behind it. */
|
||||
closesPanel?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
|
||||
import { refreshNotificationsNow } from "@app/hooks/useNotifications";
|
||||
import type { PasswordPrompt } from "@app/components/notifications/NotificationItem";
|
||||
|
||||
/**
|
||||
* The password an action asked for, owned above the panel rather than by the row that offered it:
|
||||
* the panel unmounts on any outside click, which would take a prompt a row owned with it.
|
||||
*/
|
||||
export function useNotificationPasswordPrompt(closePanel: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const [prompt, setPrompt] = useState<PasswordPrompt | null>(null);
|
||||
// Held only while the prompt is open, and dropped as soon as it closes.
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setPrompt(null);
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!prompt || busy || password === "") return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const outcome = await prompt.spec.run(prompt.context, password);
|
||||
setBusy(false);
|
||||
// The prompt stays open, so a second attempt costs a keystroke rather than a re-open.
|
||||
if (outcome && !outcome.ok) {
|
||||
setError(
|
||||
outcome.message ??
|
||||
t(
|
||||
"notifications.action.failed",
|
||||
"That did not work. Try again in a moment.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
// The incident was resolved server-side, so the list is re-read rather than patched here.
|
||||
refreshNotificationsNow();
|
||||
if (prompt.spec.closesPanel) closePanel();
|
||||
};
|
||||
|
||||
return {
|
||||
requestPassword: setPrompt,
|
||||
/* Rendered beside the panel, not inside it: it has to outlive the panel dismissing behind it. */
|
||||
promptModal: (
|
||||
<EncryptedPdfUnlockModal
|
||||
opened={prompt !== null}
|
||||
fileName={prompt?.rowTitle}
|
||||
password={password}
|
||||
errorMessage={error}
|
||||
isProcessing={busy}
|
||||
confirmLabel={
|
||||
prompt
|
||||
? t(prompt.offer.labelKey, prompt.offer.defaultLabel)
|
||||
: undefined
|
||||
}
|
||||
onPasswordChange={setPassword}
|
||||
onUnlock={() => void submit()}
|
||||
onSkip={close}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -10,10 +10,14 @@ interface EncryptedPdfUnlockModalProps {
|
||||
password: string;
|
||||
errorMessage?: string | null;
|
||||
isProcessing: boolean;
|
||||
remainingCount: number;
|
||||
/** How many other locked files are queued behind this one. Omit where there is only ever one. */
|
||||
remainingCount?: number;
|
||||
/** Confirm wording, where the caller's own reads better than the default. */
|
||||
confirmLabel?: string;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onUnlock: () => void;
|
||||
onUnlockAll: () => void;
|
||||
/** Only needed alongside a non-zero {@link EncryptedPdfUnlockModalProps.remainingCount}. */
|
||||
onUnlockAll?: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
@@ -23,7 +27,8 @@ const EncryptedPdfUnlockModal = ({
|
||||
password,
|
||||
errorMessage,
|
||||
isProcessing,
|
||||
remainingCount,
|
||||
remainingCount = 0,
|
||||
confirmLabel,
|
||||
onPasswordChange,
|
||||
onUnlock,
|
||||
onUnlockAll,
|
||||
@@ -73,7 +78,7 @@ const EncryptedPdfUnlockModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="var(--color-red-dark)" size="sm">
|
||||
<Text c="var(--color-red-dark)" size="sm" role="alert">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -89,7 +94,7 @@ const EncryptedPdfUnlockModal = ({
|
||||
{t("encryptedPdfUnlock.skip", "Skip for now")}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
{remainingCount > 0 && (
|
||||
{remainingCount > 0 && onUnlockAll && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onUnlockAll}
|
||||
@@ -106,7 +111,8 @@ const EncryptedPdfUnlockModal = ({
|
||||
loading={isProcessing}
|
||||
disabled={password.trim().length === 0}
|
||||
>
|
||||
{t("encryptedPdfUnlock.unlock", "Unlock & Continue")}
|
||||
{confirmLabel ??
|
||||
t("encryptedPdfUnlock.unlock", "Unlock & Continue")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NOTIFICATIONS_PANEL_ID,
|
||||
} from "@app/components/notifications/NotificationPanel";
|
||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||
import { useNotificationPasswordPrompt } from "@app/components/notifications/useNotificationPasswordPrompt";
|
||||
import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount";
|
||||
@@ -54,6 +55,8 @@ export function QuickNavHostBridge({
|
||||
}, [endpointReasons, toolReasons]);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const closeNotifications = useCallback(() => setNotificationsOpen(false), []);
|
||||
const { requestPassword, promptModal } =
|
||||
useNotificationPasswordPrompt(closeNotifications);
|
||||
|
||||
useRegisterQuickNavHost(
|
||||
{
|
||||
@@ -75,14 +78,21 @@ export function QuickNavHostBridge({
|
||||
},
|
||||
);
|
||||
|
||||
// Mounted only while open, so a closed panel never subscribes to the poll.
|
||||
if (!notificationsAvailable || !notificationsOpen) return null;
|
||||
if (!notificationsAvailable) return null;
|
||||
return (
|
||||
<NotificationPanel
|
||||
id={NOTIFICATIONS_PANEL_ID}
|
||||
onClose={closeNotifications}
|
||||
registry={notificationActions}
|
||||
className="notification-bell__panel--rail"
|
||||
/>
|
||||
<>
|
||||
{/* Mounted only while open, so a closed panel never subscribes to the poll. */}
|
||||
{notificationsOpen && (
|
||||
<NotificationPanel
|
||||
id={NOTIFICATIONS_PANEL_ID}
|
||||
onClose={closeNotifications}
|
||||
registry={notificationActions}
|
||||
onRequestPassword={requestPassword}
|
||||
className="notification-bell__panel--rail"
|
||||
/>
|
||||
)}
|
||||
{/* Outside the panel: an unlock closes it, and the prompt reports back afterwards. */}
|
||||
{promptModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,8 +66,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
|
||||
import type { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
|
||||
import { useResolutionContinuation } from "@app/hooks/tools/shared/useResolutionContinuation";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { reportFilesRemoved } from "@app/services/failureReporting";
|
||||
import { setPendingUnlocks } from "@app/services/pendingUnlocks";
|
||||
import { processResponse } from "@app/utils/toolResponseProcessor";
|
||||
import { ToolOperation } from "@app/types/file";
|
||||
import { handlePasswordError } from "@app/utils/toolErrorHandler";
|
||||
@@ -114,6 +116,7 @@ function FileContextInner({
|
||||
}
|
||||
const lifecycleManager = lifecycleManagerRef.current;
|
||||
const { t } = useTranslation();
|
||||
const continueResolutions = useResolutionContinuation();
|
||||
|
||||
const [encryptedQueue, setEncryptedQueue] = useState<FileId[]>([]);
|
||||
const [activeEncryptedFileId, setActiveEncryptedFileId] =
|
||||
@@ -183,6 +186,17 @@ function FileContextInner({
|
||||
}
|
||||
}, [activeEncryptedFileId, state.files.ids]);
|
||||
|
||||
// Published so an upload policy holds off until the user has answered the prompt: running now
|
||||
// would fail on a document they are about to decrypt, and leave a row about a version that no
|
||||
// longer exists once they have.
|
||||
useEffect(() => {
|
||||
setPendingUnlocks(
|
||||
activeEncryptedFileId
|
||||
? [activeEncryptedFileId, ...encryptedQueue]
|
||||
: encryptedQueue,
|
||||
);
|
||||
}, [activeEncryptedFileId, encryptedQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnlockPassword("");
|
||||
setUnlockError(null);
|
||||
@@ -448,8 +462,17 @@ function FileContextInner({
|
||||
);
|
||||
|
||||
await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]);
|
||||
|
||||
// The modal is the remove-password tool by another door, so it resolves the same.
|
||||
continueResolutions({
|
||||
operation: "removePassword",
|
||||
inputFileIds: [fileId],
|
||||
outputs: [
|
||||
{ file: unlockedFile, fileId: childStub.id, sourceFileId: fileId },
|
||||
],
|
||||
});
|
||||
},
|
||||
[consumeFilesWrapper, t],
|
||||
[consumeFilesWrapper, continueResolutions, t],
|
||||
);
|
||||
|
||||
const handleUnlockSubmit = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// A successful tool run can BE an open failure's fix. Core records none, so this is a stub.
|
||||
|
||||
/** One output of a successful tool run, paired with the input it came from where that is known. */
|
||||
export interface ToolRunOutput {
|
||||
file: File;
|
||||
/** The workspace id the output landed under, or null when it was not adopted. */
|
||||
fileId: string | null;
|
||||
/** Null where outputs are independent artifacts (merge, split), so nothing can be paired. */
|
||||
sourceFileId: string | null;
|
||||
}
|
||||
|
||||
/** A tool run that completed, as the failure system needs to see it. */
|
||||
export interface SucceededToolRun {
|
||||
operation: string;
|
||||
inputFileIds: string[];
|
||||
outputs: ToolRunOutput[];
|
||||
}
|
||||
|
||||
/** Fire-and-forget: whatever it does must never disturb the tool's own success handling. */
|
||||
export function useResolutionContinuation(): (run: SucceededToolRun) => void {
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
import type { StirlingFile } from "@app/types/fileContext";
|
||||
|
||||
// One bad file in a batch must come back named, with its error: the caller reports each one,
|
||||
// and it cannot derive the failure kind without the error the request threw.
|
||||
|
||||
const post = vi.fn();
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { post: (...args: unknown[]) => post(...args) },
|
||||
}));
|
||||
|
||||
// Mirrors the response body, so a 0-byte answer really does produce an empty output.
|
||||
vi.mock("@app/utils/toolResponseProcessor", () => ({
|
||||
processResponse: (blob: Blob, files: { name: string }[]) =>
|
||||
Promise.resolve([new File([blob], `out-${files[0].name}`)]),
|
||||
}));
|
||||
|
||||
const { useToolApiCalls } =
|
||||
await import("@app/hooks/tools/shared/useToolApiCalls");
|
||||
|
||||
const file = (name: string, id: string): StirlingFile =>
|
||||
({ name, fileId: id, size: 10 }) as unknown as StirlingFile;
|
||||
|
||||
function run(files: StirlingFile[]) {
|
||||
const { processFiles } = renderHook(() => useToolApiCalls()).result.current;
|
||||
return processFiles(
|
||||
undefined,
|
||||
files,
|
||||
{
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
buildFormData: () => new FormData(),
|
||||
},
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
});
|
||||
|
||||
describe("processFiles failure reporting", () => {
|
||||
it("names the failed input and keeps its error while the rest succeed", async () => {
|
||||
expectConsole.error("[processFiles] Failed");
|
||||
const boom = new Error("corrupted");
|
||||
let call = 0;
|
||||
post.mockImplementation(() => {
|
||||
call += 1;
|
||||
return call === 2
|
||||
? Promise.reject(boom)
|
||||
: Promise.resolve({ data: new Blob(["ok"]), status: 200, headers: {} });
|
||||
});
|
||||
|
||||
const result = await run([
|
||||
file("a.pdf", "f-a"),
|
||||
file("bad.pdf", "f-bad"),
|
||||
file("c.pdf", "f-c"),
|
||||
]);
|
||||
|
||||
expect(result.outputFiles).toHaveLength(2);
|
||||
expect(result.successSourceIds).toEqual(["f-a", "f-c"]);
|
||||
expect(result.failedInputs).toEqual([
|
||||
{ fileId: "f-bad", name: "bad.pdf", error: boom },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports nothing when every input succeeded", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: new Blob(["ok"]),
|
||||
status: 200,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const result = await run([file("a.pdf", "f-a")]);
|
||||
|
||||
expect(result.failedInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats an empty output as a failure the caller must hear about", async () => {
|
||||
// A 200 with a 0-byte body is a failure the old code counted only in a status string.
|
||||
expectConsole.warn("[processFiles] Empty output treated as failure");
|
||||
let call = 0;
|
||||
post.mockImplementation(() => {
|
||||
call += 1;
|
||||
return Promise.resolve({
|
||||
data: new Blob(call === 1 ? ["ok"] : []),
|
||||
status: 200,
|
||||
headers: {},
|
||||
});
|
||||
});
|
||||
|
||||
const result = await run([file("a.pdf", "f-a"), file("empty.pdf", "f-e")]);
|
||||
|
||||
expect(result.successSourceIds).toEqual(["f-a"]);
|
||||
expect(result.failedInputs.map((f) => f.fileId)).toEqual(["f-e"]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,13 @@ import { isEmptyOutput } from "@app/services/errorUtils";
|
||||
import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState";
|
||||
import type { StirlingFile, FileId } from "@app/types/fileContext";
|
||||
|
||||
/** An input that did not survive the batch, with the error it failed on. */
|
||||
export interface FailedInput {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
endpoint: string | null | ((params: TParams) => string | null);
|
||||
buildFormData: (params: TParams, file: File) => FormData;
|
||||
@@ -28,9 +35,16 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
onProgress: (progress: ProcessingProgress) => void,
|
||||
onStatus: (status: string) => void,
|
||||
markFileError?: (fileId: FileId) => void,
|
||||
): Promise<{ outputFiles: File[]; successSourceIds: FileId[] }> => {
|
||||
): Promise<{
|
||||
outputFiles: File[];
|
||||
successSourceIds: FileId[];
|
||||
failedInputs: FailedInput[];
|
||||
}> => {
|
||||
const processedFiles: File[] = [];
|
||||
const successSourceIds: FileId[] = [];
|
||||
// Kept with their errors: a batch where only some inputs fail still owes the caller a
|
||||
// report for each one, and it cannot derive the kind without the error.
|
||||
const failedInputs: FailedInput[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const total = validFiles.length;
|
||||
|
||||
@@ -89,6 +103,11 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
name: file.name,
|
||||
});
|
||||
failedFiles.push(file.name);
|
||||
failedInputs.push({
|
||||
fileId: file.fileId,
|
||||
name: file.name,
|
||||
error: new Error(`${endpoint} returned an empty output`),
|
||||
});
|
||||
try {
|
||||
markFileError?.(file.fileId);
|
||||
} catch (e) {
|
||||
@@ -109,6 +128,7 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
}
|
||||
console.error("[processFiles] Failed", { name: file.name, error });
|
||||
failedFiles.push(file.name);
|
||||
failedInputs.push({ fileId: file.fileId, name: file.name, error });
|
||||
// mark errored file so UI can highlight
|
||||
try {
|
||||
markFileError?.(file.fileId);
|
||||
@@ -140,7 +160,11 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
outputs: processedFiles.length,
|
||||
failed: failedFiles.length,
|
||||
});
|
||||
return { outputFiles: processedFiles, successSourceIds };
|
||||
return {
|
||||
outputFiles: processedFiles,
|
||||
successSourceIds,
|
||||
failedInputs,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -21,8 +21,15 @@ import {
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import { FILE_EVENTS } from "@app/services/errorUtils";
|
||||
import { reportToolFailure } from "@app/services/failureReporting";
|
||||
import {
|
||||
errorCodeOf,
|
||||
reportToolFailure,
|
||||
wasCancelled,
|
||||
} from "@app/services/failureReporting";
|
||||
import { stashRetryPayload } from "@app/services/notificationRetry";
|
||||
import { refreshNotificationsNow } from "@app/hooks/useNotifications";
|
||||
import { useResolutionContinuation } from "@app/hooks/tools/shared/useResolutionContinuation";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { getFilenameWithoutExtension } from "@app/utils/fileUtils";
|
||||
import {
|
||||
@@ -121,6 +128,8 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
const { checkCredits } = useCreditCheck(config.operationType, endpointString);
|
||||
const willUseCloud = useWillUseCloud(endpointString);
|
||||
const continueResolutions = useResolutionContinuation();
|
||||
const notificationsAvailable = useNotificationsAvailable();
|
||||
|
||||
// Track last operation for undo functionality
|
||||
const lastOperationRef = useRef<{
|
||||
@@ -129,6 +138,49 @@ export const useToolOperation = <TParams>(
|
||||
outputFileIds: FileId[];
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* Record a failure and keep what a retry needs. Shared with the batch's per-input failures:
|
||||
* one bad file in twenty is still a failure the user has to be told about.
|
||||
*/
|
||||
const reportFailure = useCallback(
|
||||
(
|
||||
error: unknown,
|
||||
fileIds: FileId[],
|
||||
runtimeEndpoint: string | undefined,
|
||||
params: TParams,
|
||||
) => {
|
||||
if (fileIds.length === 0 || wasCancelled(error)) return;
|
||||
|
||||
void reportToolFailure({
|
||||
operation: config.operationType,
|
||||
error,
|
||||
fileIds,
|
||||
}).then(refreshNotificationsNow);
|
||||
|
||||
// Skipped where nothing could use it: a custom processor's request cannot be replayed
|
||||
// generically, and a build with no bell has nothing to read the stash.
|
||||
if (
|
||||
!runtimeEndpoint ||
|
||||
config.toolType === ToolType.custom ||
|
||||
!notificationsAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void errorCodeOf(error).then((errorCode) =>
|
||||
stashRetryPayload({
|
||||
operation: config.operationType,
|
||||
endpoint: runtimeEndpoint,
|
||||
params: params as Record<string, unknown>,
|
||||
fileIds,
|
||||
multiFile: config.toolType === ToolType.multiFile,
|
||||
errorCode,
|
||||
recordedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[config.operationType, config.toolType, notificationsAvailable],
|
||||
);
|
||||
|
||||
const executeOperation = useCallback(
|
||||
async (params: TParams, selectedFiles: StirlingFile[]): Promise<void> => {
|
||||
// Validation
|
||||
@@ -259,9 +311,20 @@ export const useToolOperation = <TParams>(
|
||||
);
|
||||
processedFiles = result.outputFiles;
|
||||
successSourceIds = result.successSourceIds;
|
||||
// Reported here, not in the catch: this loop only throws when EVERY input failed,
|
||||
// so a batch that lost one file to a bad PDF reaches the success path.
|
||||
for (const failed of result.failedInputs) {
|
||||
reportFailure(
|
||||
failed.error,
|
||||
[failed.fileId],
|
||||
runtimeEndpoint,
|
||||
params,
|
||||
);
|
||||
}
|
||||
console.debug("[useToolOperation] Multi-file results", {
|
||||
outputFiles: processedFiles.length,
|
||||
successSources: result.successSourceIds.length,
|
||||
failedInputs: result.failedInputs.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -531,6 +594,17 @@ export const useToolOperation = <TParams>(
|
||||
})),
|
||||
outputFileIds,
|
||||
};
|
||||
|
||||
// Outputs pair with the inputs that produced them, index for index, in this branch.
|
||||
continueResolutions({
|
||||
operation: config.operationType,
|
||||
inputFileIds: validFiles.map((file) => file.fileId),
|
||||
outputs: processedFiles.map((file, index) => ({
|
||||
file,
|
||||
fileId: outputFileIds[index] ?? null,
|
||||
sourceFileId: successSourceIds[index] ?? null,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
// Outputs are independent artifacts (format conversion, merge, split).
|
||||
// Create fresh root stubs with no parent chain, then swap out only the inputs
|
||||
@@ -585,6 +659,17 @@ export const useToolOperation = <TParams>(
|
||||
})),
|
||||
outputFileIds,
|
||||
};
|
||||
|
||||
// No per-output provenance here, so only a one-in one-out run can be paired.
|
||||
continueResolutions({
|
||||
operation: config.operationType,
|
||||
inputFileIds: validFiles.map((file) => file.fileId),
|
||||
outputs: processedFiles.map((file, index) => ({
|
||||
file,
|
||||
fileId: outputFileIds[index] ?? null,
|
||||
sourceFileId: null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -602,14 +687,13 @@ export const useToolOperation = <TParams>(
|
||||
void _e;
|
||||
}
|
||||
|
||||
// Report it so a leader sees the failure too, then carry on with the user's
|
||||
// own error handling. Fire-and-forget: the reporter swallows its own errors.
|
||||
// Chained, not fired alongside: the re-read must happen after the row exists.
|
||||
void reportToolFailure({
|
||||
operation: config.operationType,
|
||||
// The whole run failed, so every input is a casualty.
|
||||
reportFailure(
|
||||
error,
|
||||
fileIds: validFiles.map((file) => file.fileId),
|
||||
}).then(refreshNotificationsNow);
|
||||
validFiles.map((file) => file.fileId),
|
||||
runtimeEndpoint,
|
||||
params,
|
||||
);
|
||||
|
||||
const errorMessage =
|
||||
config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
@@ -635,6 +719,9 @@ export const useToolOperation = <TParams>(
|
||||
extractZipFiles,
|
||||
willUseCloud,
|
||||
checkCredits,
|
||||
continueResolutions,
|
||||
notificationsAvailable,
|
||||
reportFailure,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@ vi.mock("@app/services/notifications", () => ({
|
||||
|
||||
// Counted here so "resolved once per list, not once per row" is observable.
|
||||
const hasLocalFile = vi.fn((_fileId: string) => Promise.resolve(true));
|
||||
const loadRetryPayload = vi.fn((_fileId: string) => Promise.resolve(null));
|
||||
|
||||
vi.mock("@app/services/localFilePresence", () => ({
|
||||
vi.mock("@app/services/notificationRetry", () => ({
|
||||
hasLocalFile: (fileId: string) => hasLocalFile(fileId),
|
||||
loadRetryPayload: (fileId: string) => loadRetryPayload(fileId),
|
||||
}));
|
||||
|
||||
const {
|
||||
@@ -75,6 +77,7 @@ describe("useNotifications", () => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue(feed([]));
|
||||
hasLocalFile.mockReset().mockResolvedValue(true);
|
||||
loadRetryPayload.mockReset().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("reads the list once however many bells are mounted", async () => {
|
||||
@@ -103,6 +106,7 @@ describe("useNotifications", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(3));
|
||||
expect(hasLocalFile).toHaveBeenCalledTimes(2);
|
||||
expect(loadRetryPayload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("looks up an attended run's document but never an unattended run's", async () => {
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
fetchNotifications,
|
||||
type AppNotification,
|
||||
} from "@app/services/notifications";
|
||||
import { hasLocalFile } from "@app/services/localFilePresence";
|
||||
import {
|
||||
hasLocalFile,
|
||||
loadRetryPayload,
|
||||
type RetryPayload,
|
||||
} from "@app/services/notificationRetry";
|
||||
|
||||
/**
|
||||
* One polled store for however many bells are mounted. A module store rather than a context because
|
||||
@@ -70,10 +74,12 @@ export function clearNotificationReadState(): void {
|
||||
|
||||
export interface NotificationDocumentState {
|
||||
hasLocalFile: boolean;
|
||||
retryPayload: RetryPayload | null;
|
||||
}
|
||||
|
||||
const NO_DOCUMENT: NotificationDocumentState = {
|
||||
hasLocalFile: false,
|
||||
retryPayload: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -150,6 +156,7 @@ async function read(forCycle: number): Promise<void> {
|
||||
fileId,
|
||||
{
|
||||
hasLocalFile: await hasLocalFile(fileId),
|
||||
retryPayload: await loadRetryPayload(fileId),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
|
||||
@@ -158,15 +158,10 @@ function messageOf(error: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* A user cancelling is the one failure worth dropping: nothing went wrong and there
|
||||
* is nothing for a reviewer to do. `useToolApiCalls` rethrows an axios cancellation
|
||||
* as a plain Error with the original as its cause, so both shapes are checked.
|
||||
*
|
||||
* <p>Everything else is reported, client-side refusals included: an unsupported input
|
||||
* format is the same class of problem as the processor rejecting a file type, which
|
||||
* is already recorded.
|
||||
* A user cancelling is the one failure worth dropping. Both shapes are checked, since
|
||||
* `useToolApiCalls` rethrows an axios cancellation as an Error with the original as its cause.
|
||||
*/
|
||||
function wasCancelled(error: unknown): boolean {
|
||||
export function wasCancelled(error: unknown): boolean {
|
||||
const candidate = error as {
|
||||
code?: unknown;
|
||||
name?: unknown;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
/**
|
||||
* Tests for the one thing the bell asks about a failed document here: whether it is
|
||||
* still in this browser, which is what decides if it can be opened.
|
||||
*/
|
||||
|
||||
const getStirlingFileStub = vi.fn();
|
||||
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getStirlingFileStub: (...args: unknown[]) => getStirlingFileStub(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const { hasLocalFile } = await import("@app/services/localFilePresence");
|
||||
|
||||
beforeEach(() => {
|
||||
getStirlingFileStub.mockReset().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("hasLocalFile", () => {
|
||||
it("is false once the document has left this browser", async () => {
|
||||
getStirlingFileStub.mockResolvedValue(null);
|
||||
|
||||
await expect(hasLocalFile("f-1")).resolves.toBe(false);
|
||||
await expect(hasLocalFile(null)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("is true while the document is still stored here", async () => {
|
||||
getStirlingFileStub.mockResolvedValue({ id: "f-1", name: "doc.pdf" });
|
||||
|
||||
await expect(hasLocalFile("f-1")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/** Whether the document is still in this browser. The id is this workspace's own, so only it can say. */
|
||||
export async function hasLocalFile(fileId: string | null): Promise<boolean> {
|
||||
if (!isUsableId(fileId)) return false;
|
||||
|
||||
try {
|
||||
const stub = await fileStorage.getStirlingFileStub(fileId as FileId);
|
||||
return stub !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isUsableId(fileId: string | null | undefined): fileId is string {
|
||||
return typeof fileId === "string" && fileId.trim() !== "";
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { indexedDBManager } from "@app/services/indexedDBManager";
|
||||
|
||||
// The stash survives a reload, cannot grow without bound, and never holds a password.
|
||||
|
||||
const getStirlingFileStub = vi.fn();
|
||||
const getStirlingFiles = vi.fn();
|
||||
const post = vi.fn();
|
||||
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getStirlingFileStub: (...args: unknown[]) => getStirlingFileStub(...args),
|
||||
getStirlingFiles: (...args: unknown[]) => getStirlingFiles(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { post: (...args: unknown[]) => post(...args) },
|
||||
}));
|
||||
|
||||
const {
|
||||
stashRetryPayload,
|
||||
loadRetryPayload,
|
||||
hasLocalFile,
|
||||
retryWithPassword,
|
||||
unlockLocalDocument,
|
||||
} = await import("@app/services/notificationRetry");
|
||||
|
||||
/** Duplicated from the service, which keeps its storage details private. */
|
||||
const DB_NAME = "stirling-pdf-retry";
|
||||
const STORE_NAME = "retryPayloads";
|
||||
|
||||
function payload(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
operation: "remove-password",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
multiFile: false,
|
||||
errorCode: "E004",
|
||||
recordedAt: 1_000,
|
||||
...overrides,
|
||||
} as Parameters<typeof stashRetryPayload>[0];
|
||||
}
|
||||
|
||||
/** Reads records straight out of IndexedDB, bypassing the service's own mapping. */
|
||||
async function storedRecords(): Promise<Record<string, unknown>[]> {
|
||||
const db = await indexedDBManager.openDatabase({
|
||||
name: DB_NAME,
|
||||
version: 1,
|
||||
stores: [{ name: STORE_NAME, keyPath: "fileId" }],
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = db
|
||||
.transaction([STORE_NAME], "readonly")
|
||||
.objectStore(STORE_NAME)
|
||||
.getAll();
|
||||
request.onsuccess = () =>
|
||||
resolve((request.result ?? []) as Record<string, unknown>[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
getStirlingFileStub.mockReset().mockResolvedValue(null);
|
||||
getStirlingFiles.mockReset().mockResolvedValue([]);
|
||||
post.mockReset().mockResolvedValue({ status: 200, data: new Blob() });
|
||||
await indexedDBManager.deleteDatabase(DB_NAME);
|
||||
});
|
||||
|
||||
describe("the retry stash", () => {
|
||||
it("gives back what was stashed, keyed on the file the failure was filed against", async () => {
|
||||
await stashRetryPayload(
|
||||
payload({ params: { onlyPages: "1-3" }, fileIds: ["f-1", "f-2"] }),
|
||||
);
|
||||
|
||||
await expect(loadRetryPayload("f-1")).resolves.toEqual({
|
||||
operation: "remove-password",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: { onlyPages: "1-3" },
|
||||
fileIds: ["f-1", "f-2"],
|
||||
multiFile: false,
|
||||
errorCode: "E004",
|
||||
recordedAt: 1_000,
|
||||
});
|
||||
// Every file in the run gets a record, so the bell can retry from any of them.
|
||||
await expect(loadRetryPayload("f-2")).resolves.toMatchObject({
|
||||
operation: "remove-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("has nothing for a file it never saw, or for no file at all", async () => {
|
||||
await stashRetryPayload(payload());
|
||||
|
||||
await expect(loadRetryPayload("f-other")).resolves.toBeNull();
|
||||
await expect(loadRetryPayload(null)).resolves.toBeNull();
|
||||
await expect(loadRetryPayload(" ")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the most recent operation that failed on a file, matching the server's one-incident-per-file dedup", async () => {
|
||||
await stashRetryPayload(
|
||||
payload({ operation: "compress", endpoint: "/api/v1/misc/compress-pdf" }),
|
||||
);
|
||||
await stashRetryPayload(
|
||||
payload({ operation: "rotate", endpoint: "/api/v1/general/rotate-pdf" }),
|
||||
);
|
||||
|
||||
await expect(loadRetryPayload("f-1")).resolves.toMatchObject({
|
||||
operation: "rotate",
|
||||
endpoint: "/api/v1/general/rotate-pdf",
|
||||
});
|
||||
expect(await storedRecords()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("evicts the oldest once it is full, so it cannot grow for the lifetime of the origin", async () => {
|
||||
// One past the cap: the first failure stashed is the one that goes.
|
||||
for (let i = 0; i < 26; i += 1) {
|
||||
await stashRetryPayload(payload({ fileIds: [`f-${i}`], recordedAt: i }));
|
||||
}
|
||||
|
||||
expect(await storedRecords()).toHaveLength(25);
|
||||
await expect(loadRetryPayload("f-0")).resolves.toBeNull();
|
||||
await expect(loadRetryPayload("f-25")).resolves.toMatchObject({
|
||||
operation: "remove-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores no password, whichever field the tool submitted it in", async () => {
|
||||
await stashRetryPayload(
|
||||
payload({
|
||||
params: {
|
||||
password: "hunter2",
|
||||
newOwnerPassword: "hunter2",
|
||||
passphrase: "hunter2",
|
||||
apiToken: "hunter2",
|
||||
nested: { ownerPassword: "hunter2", keep: "yes" },
|
||||
keepThese: ["a", "b"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = await storedRecords();
|
||||
expect(JSON.stringify(stored)).not.toContain("hunter2");
|
||||
// Scoped to params, since the tool this failure came from is itself called remove-password.
|
||||
expect(JSON.stringify(stored.map((record) => record.params))).not.toMatch(
|
||||
/pass(word|phrase)|token/i,
|
||||
);
|
||||
// The rest survive: without them a retry re-runs a different operation than the one that failed.
|
||||
expect((await loadRetryPayload("f-1"))?.params).toEqual({
|
||||
nested: { keep: "yes" },
|
||||
keepThese: ["a", "b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stops descending into a pathologically deep object without exhausting the stack", async () => {
|
||||
// 5000 levels: enough to overflow an unbounded walk, and nothing a tool would ever submit.
|
||||
let deep: Record<string, unknown> = { bottom: "reached" };
|
||||
for (let i = 0; i < 5000; i++) deep = { down: deep };
|
||||
|
||||
await expect(
|
||||
stashRetryPayload(payload({ params: { deep } })),
|
||||
).resolves.toBeUndefined();
|
||||
expect(await loadRetryPayload("f-1")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("drops a secret sitting just past the depth limit rather than passing the subtree through", async () => {
|
||||
// Only a little past the limit: a far deeper object would fail to store and pass vacuously.
|
||||
let past: Record<string, unknown> = { password: "hunter2" };
|
||||
for (let i = 0; i < 25; i++) past = { down: past };
|
||||
|
||||
await stashRetryPayload(payload({ params: { past } }));
|
||||
|
||||
// Where the walk gives up it must not hand back a subtree it never examined.
|
||||
expect(JSON.stringify(await storedRecords())).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("survives a cycle in the parameters", async () => {
|
||||
// A depth bound is what saves this: a cycle has no leaves to reach.
|
||||
const cyclic: Record<string, unknown> = { keep: "yes" };
|
||||
cyclic.self = cyclic;
|
||||
|
||||
await expect(
|
||||
stashRetryPayload(payload({ params: { cyclic } })),
|
||||
).resolves.toBeUndefined();
|
||||
expect(await loadRetryPayload("f-1")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasLocalFile", () => {
|
||||
it("is false once the document has left this browser", async () => {
|
||||
getStirlingFileStub.mockResolvedValue(null);
|
||||
|
||||
await expect(hasLocalFile("f-1")).resolves.toBe(false);
|
||||
await expect(hasLocalFile(null)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("is true while the document is still stored here", async () => {
|
||||
getStirlingFileStub.mockResolvedValue({ id: "f-1", name: "doc.pdf" });
|
||||
|
||||
await expect(hasLocalFile("f-1")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryWithPassword", () => {
|
||||
it("reports the file is gone instead of throwing, which is an expected outcome here", async () => {
|
||||
getStirlingFiles.mockResolvedValue([]);
|
||||
|
||||
const result = await retryWithPassword(payload(), "hunter2");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
// The reason, not words: the component layer owns the wording, having `t`.
|
||||
expect(result.reason).toBe("fileMissing");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-submits the stashed operation with the password added", async () => {
|
||||
getStirlingFiles.mockResolvedValue([
|
||||
new File(["%PDF-1.7"], "doc.pdf", { type: "application/pdf" }),
|
||||
]);
|
||||
|
||||
const result = await retryWithPassword(
|
||||
payload({ params: { onlyPages: "1-3" } }),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const [path, formData] = post.mock.calls[0] as [string, FormData];
|
||||
expect(path).toBe("/api/v1/security/remove-password");
|
||||
expect(formData.get("password")).toBe("hunter2");
|
||||
expect(formData.get("onlyPages")).toBe("1-3");
|
||||
expect(formData.get("fileInput")).toBeInstanceOf(File);
|
||||
// The password was used for the one call and nothing else.
|
||||
expect(JSON.stringify(await storedRecords())).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("hands the output back, since a retry the user cannot see the result of is no retry", async () => {
|
||||
getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "doc.pdf")]);
|
||||
const unlocked = new Blob(["unlocked"]);
|
||||
post.mockResolvedValue({
|
||||
data: unlocked,
|
||||
headers: {
|
||||
"content-disposition": 'attachment; filename="doc_unlocked.pdf"',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await retryWithPassword(payload(), "hunter2");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files?.[0].filename).toBe("doc_unlocked.pdf");
|
||||
// The response body itself, so the caller adopts the bytes the server sent.
|
||||
expect(result.files?.[0].blob).toBe(unlocked);
|
||||
});
|
||||
|
||||
it("names the output after its input when the server sent no filename", async () => {
|
||||
getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "doc.pdf")]);
|
||||
post.mockResolvedValue({ data: new Blob(["unlocked"]), headers: {} });
|
||||
|
||||
const result = await retryWithPassword(payload(), "hunter2");
|
||||
|
||||
expect(result.files?.[0].filename).toBe("doc.pdf");
|
||||
});
|
||||
|
||||
it("returns the server's own message when the retry fails again", async () => {
|
||||
getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "doc.pdf")]);
|
||||
post.mockRejectedValue({
|
||||
response: { data: "The password is incorrect." },
|
||||
message: "Request failed with status code 400",
|
||||
});
|
||||
|
||||
const result = await retryWithPassword(payload(), "hunter2");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toBe("The password is incorrect.");
|
||||
expect(result.message).not.toContain("hunter2");
|
||||
});
|
||||
});
|
||||
|
||||
/** The unlock for a failure with no stash behind it: fixed endpoint, no payload. */
|
||||
describe("unlockLocalDocument", () => {
|
||||
it("removes the password from the document this browser holds, and stores nothing", async () => {
|
||||
getStirlingFiles.mockResolvedValue([
|
||||
new File(["%PDF-1.7"], "locked.pdf", { type: "application/pdf" }),
|
||||
]);
|
||||
post.mockResolvedValue({
|
||||
data: new Blob(["unlocked"]),
|
||||
headers: {
|
||||
"content-disposition": 'attachment; filename="locked_unlocked.pdf"',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await unlockLocalDocument("f-1", "hunter2");
|
||||
|
||||
const [path, formData] = post.mock.calls[0] as [string, FormData];
|
||||
expect(path).toBe("/api/v1/security/remove-password");
|
||||
expect(formData.get("password")).toBe("hunter2");
|
||||
expect(formData.get("fileInput")).toBeInstanceOf(File);
|
||||
expect(result.files?.[0].filename).toBe("locked_unlocked.pdf");
|
||||
// The password was used for the one call and nothing else: no stash is written here at all.
|
||||
expect(await storedRecords()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports the document is gone instead of posting a password nowhere", async () => {
|
||||
getStirlingFiles.mockResolvedValue([]);
|
||||
|
||||
const result = await unlockLocalDocument("f-1", "hunter2");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("fileMissing");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the server's own message when the password is wrong", async () => {
|
||||
getStirlingFiles.mockResolvedValue([new File(["%PDF-1.7"], "locked.pdf")]);
|
||||
post.mockRejectedValue({
|
||||
response: { data: "The password is incorrect." },
|
||||
message: "Request failed with status code 400",
|
||||
});
|
||||
|
||||
const result = await unlockLocalDocument("f-1", "wrong");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toBe("The password is incorrect.");
|
||||
expect(result.message).not.toContain("wrong");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import {
|
||||
indexedDBManager,
|
||||
type DatabaseConfig,
|
||||
} from "@app/services/indexedDBManager";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
/** What the bell needs to retry a reported failure. The server keeps none of it. */
|
||||
export interface RetryPayload {
|
||||
operation: string;
|
||||
endpoint: string;
|
||||
params: Record<string, unknown>;
|
||||
fileIds: string[];
|
||||
/** Whether the endpoint takes the whole batch in one call, or one file per call. */
|
||||
multiFile: boolean;
|
||||
/** The failure's error code, so a stash can be matched to the row's kind. */
|
||||
errorCode: string | null;
|
||||
recordedAt: number;
|
||||
}
|
||||
|
||||
/** Mirrored from the server's `FailureKind` declarations. */
|
||||
const KIND_ERROR_CODES: Record<string, string> = {
|
||||
INPUT_PASSWORD_PROTECTED: "E004",
|
||||
};
|
||||
|
||||
/** Whether the one stash a file carries is the failure this row describes. */
|
||||
export function stashMatchesKind(
|
||||
kindId: string,
|
||||
payload: RetryPayload,
|
||||
): boolean {
|
||||
const claimed = KIND_ERROR_CODES[kindId];
|
||||
if (claimed) return payload.errorCode === claimed;
|
||||
return (
|
||||
payload.errorCode === null ||
|
||||
!Object.values(KIND_ERROR_CODES).includes(payload.errorCode)
|
||||
);
|
||||
}
|
||||
|
||||
/** Its own database: the files schema is at v9, and this hint is safe to lose. */
|
||||
const RETRY_DB_CONFIG: DatabaseConfig = {
|
||||
name: "stirling-pdf-retry",
|
||||
version: 1,
|
||||
stores: [{ name: "retryPayloads", keyPath: "fileId" }],
|
||||
};
|
||||
|
||||
const STORE_NAME = "retryPayloads";
|
||||
|
||||
/** Capped, oldest evicted first, so the stash cannot grow for the origin's lifetime. */
|
||||
const MAX_RETAINED_PAYLOADS = 25;
|
||||
|
||||
/** One record per file involved, so a retry can be found from any of them. */
|
||||
interface StoredRetryRecord extends RetryPayload {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/** Stripped on the way in: remove-password submits its password as a parameter. */
|
||||
const SECRET_FIELD = /pass(word|phrase)|secret|token|credential/i;
|
||||
|
||||
/** Never rejects: a browser refusing IndexedDB costs the retry button, not a second error. */
|
||||
export async function stashRetryPayload(payload: RetryPayload): Promise<void> {
|
||||
try {
|
||||
const fileIds = payload.fileIds.filter(isUsableId);
|
||||
if (!payload.operation.trim() || fileIds.length === 0) return;
|
||||
|
||||
const record = {
|
||||
...payload,
|
||||
fileIds,
|
||||
params: withoutSecrets(payload.params),
|
||||
};
|
||||
|
||||
await writeRecords(fileIds.map((fileId) => ({ ...record, fileId })));
|
||||
} catch {
|
||||
// Nothing to recover: the bell simply offers no retry for this failure.
|
||||
}
|
||||
}
|
||||
|
||||
/** The most recent operation that failed on this file, or null when nothing is stashed. */
|
||||
export async function loadRetryPayload(
|
||||
fileId: string | null,
|
||||
): Promise<RetryPayload | null> {
|
||||
if (!isUsableId(fileId)) return null;
|
||||
|
||||
let record: StoredRetryRecord | undefined;
|
||||
try {
|
||||
record = await readRecord(fileId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!record) return null;
|
||||
|
||||
// An older shape is unusable rather than half-usable: a retry needs somewhere to go.
|
||||
if (!record.operation || !record.endpoint) return null;
|
||||
|
||||
return {
|
||||
operation: record.operation,
|
||||
endpoint: record.endpoint,
|
||||
params: record.params ?? {},
|
||||
fileIds: record.fileIds ?? [fileId],
|
||||
// Older records predate these fields; both defaults fail closed.
|
||||
multiFile: record.multiFile ?? false,
|
||||
errorCode: record.errorCode ?? null,
|
||||
recordedAt: record.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the document is still in this browser, which decides whether a retry can run. */
|
||||
export async function hasLocalFile(fileId: string | null): Promise<boolean> {
|
||||
if (!isUsableId(fileId)) return false;
|
||||
|
||||
try {
|
||||
const stub = await fileStorage.getStirlingFileStub(fileId as FileId);
|
||||
return stub !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A file the retry produced, handed back for the caller to adopt. */
|
||||
export interface RetryOutputFile {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/** Why a retry could not run; `serverMessage` means the message is the server's own words. */
|
||||
export type PasswordRetryFailure =
|
||||
| "notRetryable"
|
||||
| "fileMissing"
|
||||
| "serverMessage";
|
||||
|
||||
/** What a password-carrying call comes back with. `files` only ever on success. */
|
||||
export interface PasswordRetryOutcome {
|
||||
ok: boolean;
|
||||
reason?: PasswordRetryFailure;
|
||||
message?: string | null;
|
||||
files?: RetryOutputFile[];
|
||||
}
|
||||
|
||||
/** Checked against the generated endpoints, so a renamed route fails the build here. */
|
||||
const UNLOCK_ENDPOINT =
|
||||
"/api/v1/security/remove-password" satisfies ToolEndpoint;
|
||||
|
||||
/** Unlock a held document for a failure with no stashed operation, e.g. a policy run. */
|
||||
export async function unlockLocalDocument(
|
||||
fileId: string,
|
||||
password: string,
|
||||
): Promise<PasswordRetryOutcome> {
|
||||
return postWithPassword(UNLOCK_ENDPOINT, {}, [fileId], password);
|
||||
}
|
||||
|
||||
/** Re-runs the stashed operation: `forFileId` alone, or the whole batch for a multi-file endpoint. */
|
||||
export async function retryWithPassword(
|
||||
payload: RetryPayload,
|
||||
password: string,
|
||||
forFileId: string | null = null,
|
||||
): Promise<PasswordRetryOutcome> {
|
||||
if (!payload.endpoint) {
|
||||
return { ok: false, reason: "notRetryable", message: null };
|
||||
}
|
||||
|
||||
const fileIds = payload.multiFile
|
||||
? payload.fileIds
|
||||
: [
|
||||
forFileId && payload.fileIds.includes(forFileId)
|
||||
? forFileId
|
||||
: payload.fileIds[0],
|
||||
];
|
||||
|
||||
return postWithPassword(payload.endpoint, payload.params, fileIds, password);
|
||||
}
|
||||
|
||||
/** Shared by both callers above, so a password reaches the network from one place only. */
|
||||
async function postWithPassword(
|
||||
endpoint: string,
|
||||
params: Record<string, unknown>,
|
||||
requestedFileIds: (string | null | undefined)[],
|
||||
password: string,
|
||||
): Promise<PasswordRetryOutcome> {
|
||||
const fileIds = requestedFileIds.filter(isUsableId);
|
||||
let files: File[] = [];
|
||||
try {
|
||||
files = await fileStorage.getStirlingFiles(fileIds as FileId[]);
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
|
||||
// getStirlingFiles drops what it cannot find, so a short result means an input is gone.
|
||||
if (files.length === 0 || files.length !== fileIds.length) {
|
||||
return { ok: false, reason: "fileMissing", message: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = toFormData(params, files);
|
||||
formData.append("password", password);
|
||||
const response = await apiClient.post<Blob>(endpoint, formData, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
files: await asOutputFiles(
|
||||
response.data,
|
||||
filenameOf(response.headers, files[0].name),
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, reason: "serverMessage", message: messageOf(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** A multi-output run answers with a ZIP, which must not land in the workbench as one PDF. */
|
||||
async function asOutputFiles(
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
): Promise<RetryOutputFile[]> {
|
||||
if (await zipFileService.isZipResponse(blob)) {
|
||||
const extracted = await zipFileService.extractPdfFiles(
|
||||
new File([blob], filename),
|
||||
);
|
||||
if (extracted.success && extracted.extractedFiles.length > 0) {
|
||||
return extracted.extractedFiles.map((file) => ({
|
||||
blob: file,
|
||||
filename: file.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return [{ blob, filename }];
|
||||
}
|
||||
|
||||
/** Falls back to the input's name, so an unnamed blob is not adopted as "blob". */
|
||||
function filenameOf(headers: unknown, fallback: string): string {
|
||||
const disposition = (headers as Record<string, unknown> | undefined)?.[
|
||||
"content-disposition"
|
||||
];
|
||||
if (typeof disposition !== "string") return fallback;
|
||||
|
||||
// filename* (RFC 5987) wins over plain filename: that is how a non-ASCII name arrives.
|
||||
const encoded = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(disposition)?.[1];
|
||||
const plain = /filename="?([^";]+)"?/i.exec(disposition)?.[1];
|
||||
const name = encoded ?? plain;
|
||||
if (!name) return fallback;
|
||||
|
||||
try {
|
||||
return decodeURIComponent(name.trim().replace(/^"|"$/g, "")) || fallback;
|
||||
} catch {
|
||||
// A malformed escape is not worth failing an otherwise successful retry over.
|
||||
return name.trim().replace(/^"|"$/g, "") || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isUsableId(fileId: string | null | undefined): fileId is string {
|
||||
return typeof fileId === "string" && fileId.trim() !== "";
|
||||
}
|
||||
|
||||
/** Not `objectToFormData`: that is typed to the generated union and throws on a stashed record. */
|
||||
function toFormData(params: Record<string, unknown>, files: File[]): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) formData.append(key, asField(item));
|
||||
} else {
|
||||
formData.append(key, asField(value));
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) formData.append("fileInput", file);
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
function asField(value: unknown): string {
|
||||
return typeof value === "object" ? JSON.stringify(value) : `${value}`;
|
||||
}
|
||||
|
||||
/** Far above any real tool's nesting; exists so a cyclic object cannot exhaust the stack. */
|
||||
const MAX_PARAM_DEPTH = 20;
|
||||
|
||||
/** Stands in for a subtree too deep to walk. */
|
||||
const TOO_DEEP = "[nested too deeply to store]";
|
||||
|
||||
/** Secrets dropped at any depth; past the limit the subtree is replaced, never returned unseen. */
|
||||
function withoutSecrets(
|
||||
value: Record<string, unknown>,
|
||||
): Record<string, unknown>;
|
||||
function withoutSecrets(value: unknown): unknown;
|
||||
function withoutSecrets(value: unknown): unknown {
|
||||
return prunedBelow(value, 0);
|
||||
}
|
||||
|
||||
function prunedBelow(value: unknown, depth: number): unknown {
|
||||
if (depth >= MAX_PARAM_DEPTH) return TOO_DEEP;
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => prunedBelow(item, depth + 1));
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
|
||||
const kept: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (SECRET_FIELD.test(key)) continue;
|
||||
kept[key] = prunedBelow(nested, depth + 1);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** What the server said, or null when it said nothing usable. Never carries the password. */
|
||||
function messageOf(error: unknown): string | null {
|
||||
const response = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
if (typeof response === "string" && response.trim() !== "") return response;
|
||||
|
||||
const message = (error as { message?: unknown })?.message;
|
||||
return typeof message === "string" && message.trim() !== "" ? message : null;
|
||||
}
|
||||
|
||||
async function writeRecords(records: StoredRetryRecord[]): Promise<void> {
|
||||
const db = await indexedDBManager.openDatabase(RETRY_DB_CONFIG);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], "readwrite");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("Retry stash transaction aborted"));
|
||||
|
||||
// put, not add: last write wins per fileId, matching the server's dedup.
|
||||
for (const record of records) store.put(record);
|
||||
|
||||
// Evicted in the same transaction, so two concurrent stashes cannot both see room.
|
||||
const all = store.getAll();
|
||||
all.onsuccess = () => {
|
||||
const stored = (all.result ?? []) as StoredRetryRecord[];
|
||||
const excess = stored.length - MAX_RETAINED_PAYLOADS;
|
||||
if (excess <= 0) return;
|
||||
stored
|
||||
.sort((a, b) => a.recordedAt - b.recordedAt)
|
||||
.slice(0, excess)
|
||||
.forEach((record) => store.delete(record.fileId));
|
||||
};
|
||||
all.onerror = () => reject(all.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function readRecord(
|
||||
fileId: string,
|
||||
): Promise<StoredRetryRecord | undefined> {
|
||||
const db = await indexedDBManager.openDatabase(RETRY_DB_CONFIG);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([STORE_NAME], "readonly");
|
||||
const request = transaction.objectStore(STORE_NAME).get(fileId);
|
||||
request.onsuccess = () =>
|
||||
resolve(request.result as StoredRetryRecord | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The uploads still waiting on their unlock prompt. Published by the workbench, read by anything
|
||||
* that would otherwise act on a document the user is in the middle of decrypting.
|
||||
*
|
||||
* A module store rather than context: the reader is a policy hook in another layer, and it needs
|
||||
* the answer during an effect rather than as a render input.
|
||||
*/
|
||||
|
||||
const pending = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Replaces the set wholesale, since the prompt queue is authoritative about who is waiting. */
|
||||
export function setPendingUnlocks(fileIds: readonly string[]): void {
|
||||
const next = new Set(fileIds);
|
||||
if (next.size === pending.size && [...next].every((id) => pending.has(id))) {
|
||||
return;
|
||||
}
|
||||
pending.clear();
|
||||
for (const id of next) pending.add(id);
|
||||
version += 1;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this document is still awaiting an unlock decision. False once the user has unlocked it
|
||||
* (the document is replaced by a decrypted version) or skipped it (they have chosen to go on).
|
||||
*/
|
||||
export function isAwaitingUnlock(fileId: string): boolean {
|
||||
return pending.has(fileId);
|
||||
}
|
||||
|
||||
let version = 0;
|
||||
|
||||
/** Changes whenever the set does, so a subscriber can re-run work it skipped. */
|
||||
export function pendingUnlocksVersion(): number {
|
||||
return version;
|
||||
}
|
||||
|
||||
export function subscribeToPendingUnlocks(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
+696
-12
@@ -8,10 +8,33 @@ import type {
|
||||
} from "@app/services/notifications";
|
||||
import type { NotificationActionContext } from "@core/components/notifications/notificationActions";
|
||||
|
||||
/**
|
||||
* Where each action sends the reader. Only the editor has the workbench contexts above it, so the two
|
||||
* shells are the interesting cases: opening the document, or handing it over.
|
||||
*/
|
||||
// Where each action sends the reader. Only the editor has the workbench contexts above it.
|
||||
|
||||
const retryWithPassword = vi.fn();
|
||||
const unlockLocalDocument = vi.fn();
|
||||
vi.mock("@app/services/notificationRetry", async (importOriginal) => ({
|
||||
// The real stashMatchesKind: it is pure, and its guard is part of what these tests exercise.
|
||||
...(await importOriginal<typeof import("@app/services/notificationRetry")>()),
|
||||
retryWithPassword: (...args: unknown[]) => retryWithPassword(...args),
|
||||
unlockLocalDocument: (...args: unknown[]) => unlockLocalDocument(...args),
|
||||
}));
|
||||
|
||||
const rerunPolicy = vi.fn();
|
||||
const rechainPolicyOnDocument = vi.fn();
|
||||
vi.mock("@app/services/notificationPolicyRetry", () => ({
|
||||
rerunPolicy: (...args: unknown[]) => rerunPolicy(...args),
|
||||
rechainPolicyOnDocument: (...args: unknown[]) =>
|
||||
rechainPolicyOnDocument(...args),
|
||||
}));
|
||||
|
||||
const reportNotificationResolved = vi.fn();
|
||||
vi.mock("@app/services/notifications", async () => ({
|
||||
...(await vi.importActual<typeof import("@app/services/notifications")>(
|
||||
"@app/services/notifications",
|
||||
)),
|
||||
reportNotificationResolved: (...args: unknown[]) =>
|
||||
reportNotificationResolved(...args),
|
||||
}));
|
||||
|
||||
const navigate = vi.fn();
|
||||
vi.mock("react-router-dom", async () => ({
|
||||
@@ -52,8 +75,26 @@ const { useNotificationActions } =
|
||||
const addStirlingFileStubs = vi.fn();
|
||||
const setActiveFileId = vi.fn();
|
||||
const setWorkbench = vi.fn();
|
||||
const setToolAndWorkbench = vi.fn();
|
||||
// createChildStub is stubbed too, so the test can name the version's id directly.
|
||||
vi.mock("@app/contexts/file/fileActions", () => ({
|
||||
generateProcessedFileMetadata: () => Promise.resolve(null),
|
||||
createChildStub: (parent: { id: string }, _op: unknown, file: File) => ({
|
||||
...parent,
|
||||
id: "f-unlocked",
|
||||
name: file.name,
|
||||
versionNumber: 2,
|
||||
parentFileId: parent.id,
|
||||
}),
|
||||
}));
|
||||
|
||||
/** What the workbench already holds, so the "do not add it twice" path can be exercised. */
|
||||
let openFileIds: string[] = [];
|
||||
/** Stubs the workbench holds, so the in-place replacement path has an original to version. */
|
||||
let openFilesById: Record<string, unknown> = {};
|
||||
const setSelectedFiles = vi.fn();
|
||||
const addFiles = vi.fn();
|
||||
const consumeFiles = vi.fn();
|
||||
|
||||
function notification(
|
||||
overrides: Partial<AppNotification> = {},
|
||||
@@ -97,6 +138,31 @@ function context(
|
||||
return {
|
||||
notification: notification(),
|
||||
hasLocalFile: true,
|
||||
retryPayload: {
|
||||
operation: "removePassword",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
multiFile: false,
|
||||
errorCode: "E004",
|
||||
recordedAt: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** An attended policy run: the row names the policy and the document, and nothing was stashed. */
|
||||
function policyContext(
|
||||
overrides: Partial<NotificationActionContext> = {},
|
||||
): NotificationActionContext {
|
||||
return {
|
||||
notification: notification({
|
||||
origin: "POLICY",
|
||||
policyId: "pol-1",
|
||||
sourceId: null,
|
||||
}),
|
||||
hasLocalFile: true,
|
||||
retryPayload: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -106,21 +172,28 @@ const inEditor = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter>
|
||||
<FileActionsContext.Provider
|
||||
value={{
|
||||
actions: { addStirlingFileStubs } as never,
|
||||
actions: {
|
||||
addStirlingFileStubs,
|
||||
setSelectedFiles,
|
||||
addFiles,
|
||||
consumeFiles,
|
||||
} as never,
|
||||
dispatch: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<FileStoreContext.Provider
|
||||
value={
|
||||
{
|
||||
getState: () => ({ files: { ids: openFileIds } }),
|
||||
getState: () => ({
|
||||
files: { ids: openFileIds, byId: openFilesById },
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
selectors: {},
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<NavigationActionsContext.Provider
|
||||
value={{ actions: { setWorkbench } } as never}
|
||||
value={{ actions: { setWorkbench, setToolAndWorkbench } } as never}
|
||||
>
|
||||
<ViewerContext.Provider value={{ setActiveFileId } as never}>
|
||||
{children}
|
||||
@@ -140,22 +213,106 @@ function registry(wrapper = inEditor) {
|
||||
return renderHook(() => useNotificationActions(), { wrapper }).result.current;
|
||||
}
|
||||
|
||||
/** A file's own bytes. Via FileReader because this environment's Blob has no `text`. */
|
||||
function bytesOf(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
addStirlingFileStubs.mockReset().mockResolvedValue([]);
|
||||
setActiveFileId.mockReset();
|
||||
setWorkbench.mockReset();
|
||||
setToolAndWorkbench.mockReset();
|
||||
h.getStirlingFileStub.mockReset().mockResolvedValue(h.stub);
|
||||
openFileIds = [];
|
||||
openFilesById = {};
|
||||
setSelectedFiles.mockReset();
|
||||
consumeFiles.mockReset().mockResolvedValue(["f-unlocked"]);
|
||||
// The adopted document's own id, not the reference the failure was filed against.
|
||||
addFiles.mockReset().mockResolvedValue([{ fileId: "f-unlocked" }]);
|
||||
reportNotificationResolved.mockReset().mockResolvedValue(true);
|
||||
retryWithPassword.mockReset().mockResolvedValue({ ok: true, files: [] });
|
||||
// The unlock succeeds by default: most cases below are about what happens afterwards.
|
||||
unlockLocalDocument.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
files: [
|
||||
{
|
||||
blob: new Blob(["pdf"], { type: "application/pdf" }),
|
||||
filename: "invoice.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
// Tracked by default: something is polling the run, so its output will arrive.
|
||||
rerunPolicy.mockReset().mockResolvedValue({ ok: true, tracked: true });
|
||||
rechainPolicyOnDocument.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
tracked: true,
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
window.history.pushState({}, "", "/");
|
||||
});
|
||||
|
||||
describe("useNotificationActions", () => {
|
||||
it("offers to open the document only while it is still in this browser", () => {
|
||||
it("opens the failed tool on the failed document alone, in the viewer", async () => {
|
||||
await registry().RETRY?.run(context());
|
||||
|
||||
expect(addStirlingFileStubs).toHaveBeenCalledWith([h.stub]);
|
||||
expect(setActiveFileId).toHaveBeenCalledWith("f-1");
|
||||
// The viewer scopes the tool to this one file. Any other view would hand it the whole
|
||||
// workbench, so a retry on one failed document would re-run across every open file.
|
||||
expect(setToolAndWorkbench).toHaveBeenCalledWith(
|
||||
"removePassword",
|
||||
"viewer",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the document alone when the stashed operation names no tool this build has", async () => {
|
||||
await registry().RETRY?.run(
|
||||
context({
|
||||
retryPayload: {
|
||||
operation: "quarantine",
|
||||
endpoint: "/api/v1/quarantine",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
multiFile: false,
|
||||
errorCode: "E004",
|
||||
recordedAt: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setActiveFileId).toHaveBeenCalledWith("f-1");
|
||||
expect(setWorkbench).toHaveBeenCalledWith("viewer");
|
||||
expect(setToolAndWorkbench).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a retry whose document has gone from storage rather than opening nothing", async () => {
|
||||
h.getStirlingFileStub.mockResolvedValue(null);
|
||||
|
||||
const outcome = await registry().RETRY?.run(context());
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
message: "This document can no longer be retried from this browser.",
|
||||
});
|
||||
expect(setToolAndWorkbench).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no retry once the document has left this browser", () => {
|
||||
const actions = registry();
|
||||
|
||||
expect(actions.VIEW_FILE?.available(context())).toBe(true);
|
||||
expect(actions.RETRY?.available(context({ hasLocalFile: false }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(actions.RETRY?.available(context({ retryPayload: null }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(actions.VIEW_FILE?.available(context({ hasLocalFile: false }))).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -214,27 +371,233 @@ describe("useNotificationActions", () => {
|
||||
// The intent outlives the navigation that mounts the editor.
|
||||
expect(
|
||||
window.sessionStorage.getItem("stirling.notifications.pendingSelection"),
|
||||
).toBe("f-1");
|
||||
).toBe(JSON.stringify({ fileId: "f-1", tool: null }));
|
||||
// The editor's own URL, not the role router at "/".
|
||||
expect(window.location.pathname).toBe("/editor");
|
||||
});
|
||||
|
||||
it("hands the tool over with the document, so a retry arrives scoped", async () => {
|
||||
await registry(inProcessor).RETRY?.run(context());
|
||||
|
||||
expect(
|
||||
window.sessionStorage.getItem("stirling.notifications.pendingSelection"),
|
||||
).toBe(JSON.stringify({ fileId: "f-1", tool: "removePassword" }));
|
||||
expect(window.location.pathname).toBe("/remove-password");
|
||||
});
|
||||
|
||||
it("picks up a handed-over document as soon as an editor is there", async () => {
|
||||
window.sessionStorage.setItem(
|
||||
"stirling.notifications.pendingSelection",
|
||||
"f-9",
|
||||
JSON.stringify({ fileId: "f-9", tool: null }),
|
||||
);
|
||||
|
||||
registry();
|
||||
await vi.waitFor(() => expect(setActiveFileId).toHaveBeenCalledWith("f-9"));
|
||||
|
||||
expect(setWorkbench).toHaveBeenCalledWith("viewer");
|
||||
expect(setToolAndWorkbench).not.toHaveBeenCalled();
|
||||
// One-shot: a later mount must not reopen a document the user has moved on from.
|
||||
expect(
|
||||
window.sessionStorage.getItem("stirling.notifications.pendingSelection"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("arrives scoped, so a retry handed over from the processor runs on one file", async () => {
|
||||
window.sessionStorage.setItem(
|
||||
"stirling.notifications.pendingSelection",
|
||||
JSON.stringify({ fileId: "f-9", tool: "removePassword" }),
|
||||
);
|
||||
|
||||
registry();
|
||||
|
||||
// One dispatch, not a workbench change the URL sync could then overwrite with a stale view.
|
||||
await vi.waitFor(() =>
|
||||
expect(setToolAndWorkbench).toHaveBeenCalledWith(
|
||||
"removePassword",
|
||||
"viewer",
|
||||
),
|
||||
);
|
||||
expect(setWorkbench).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a handed-over tool this build does not have", async () => {
|
||||
window.sessionStorage.setItem(
|
||||
"stirling.notifications.pendingSelection",
|
||||
JSON.stringify({ fileId: "f-9", tool: "quarantine" }),
|
||||
);
|
||||
|
||||
registry();
|
||||
await vi.waitFor(() => expect(setWorkbench).toHaveBeenCalledWith("viewer"));
|
||||
|
||||
expect(setToolAndWorkbench).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unlocks with the password it was given and reports what came back", async () => {
|
||||
retryWithPassword.mockResolvedValue({ ok: false, message: "Wrong" });
|
||||
|
||||
const outcome = await registry().DECRYPT_AND_RETRY?.run(
|
||||
context(),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(retryWithPassword).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: "/api/v1/security/remove-password" }),
|
||||
"hunter2",
|
||||
// The row's own document, so a single-file endpoint is not handed the whole batch.
|
||||
"f-1",
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, message: "Wrong" });
|
||||
});
|
||||
|
||||
it("takes the unlocked document into the workbench through FileContext", async () => {
|
||||
// The whole point of the password: the user must end up holding the unlocked file.
|
||||
retryWithPassword.mockResolvedValue({
|
||||
ok: true,
|
||||
files: [
|
||||
{
|
||||
blob: new Blob(["pdf"], { type: "application/pdf" }),
|
||||
filename: "invoice.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await registry().DECRYPT_AND_RETRY?.run(
|
||||
context(),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
const [files, options] = addFiles.mock.calls[0];
|
||||
expect((files as File[]).map((file) => file.name)).toEqual(["invoice.pdf"]);
|
||||
// Selected so it is on screen, and marked in-app so `usePolicyAutoRun` leaves it alone.
|
||||
expect(options).toEqual({ selectFiles: true, derivedFromTool: true });
|
||||
// And closed with the prefixed id: nothing else tells the server the client fixed it.
|
||||
expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-1");
|
||||
});
|
||||
|
||||
it("replaces the encrypted original in place when it is open in the workbench", async () => {
|
||||
// The failed document is on screen, so the unlock versions it rather than adding a second copy.
|
||||
openFileIds = ["f-1"];
|
||||
openFilesById = {
|
||||
"f-1": { id: "f-1", name: "invoice.pdf", versionNumber: 1 },
|
||||
};
|
||||
unlockLocalDocument.mockResolvedValue({
|
||||
ok: true,
|
||||
files: [
|
||||
{
|
||||
blob: new Blob(["pdf"], { type: "application/pdf" }),
|
||||
filename: "invoice.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await registry().DECRYPT_AND_RETRY?.run(
|
||||
policyContext(),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
// Consumed, not added: the encrypted original is versioned, so there is only ever one document.
|
||||
expect(addFiles).not.toHaveBeenCalled();
|
||||
const [inputIds, , stubs] = consumeFiles.mock.calls[0];
|
||||
expect(inputIds).toEqual(["f-1"]);
|
||||
// Still in-app, so usePolicyAutoRun does not enforce the chain on it — the rechain does that.
|
||||
expect(
|
||||
(stubs as Array<{ derivedFromTool?: boolean }>)[0].derivedFromTool,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("versions a document the workbench has closed, rather than adding a copy of it", async () => {
|
||||
// Closed in the sidebar but still on the device: adding here left the user holding both.
|
||||
openFileIds = [];
|
||||
openFilesById = {};
|
||||
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2");
|
||||
|
||||
expect(addFiles).not.toHaveBeenCalled();
|
||||
expect(consumeFiles.mock.calls[0][0]).toEqual(["f-1"]);
|
||||
});
|
||||
|
||||
it("adds the unlocked document when nothing on this device holds the original", async () => {
|
||||
// No stub anywhere: there is no version chain to extend, so adding is all that is left.
|
||||
h.getStirlingFileStub.mockResolvedValue(null);
|
||||
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2");
|
||||
|
||||
expect(consumeFiles).not.toHaveBeenCalled();
|
||||
expect(addFiles.mock.calls[0][1]).toEqual({
|
||||
selectFiles: true,
|
||||
derivedFromTool: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the incident only once the document is safely in", async () => {
|
||||
// Reported first, then a failed adoption, would leave the row closed with nothing to show.
|
||||
retryWithPassword.mockResolvedValue({
|
||||
ok: true,
|
||||
files: [{ blob: new Blob(["pdf"]), filename: "invoice.pdf" }],
|
||||
});
|
||||
addFiles.mockRejectedValue(new Error("quota"));
|
||||
|
||||
await registry().DECRYPT_AND_RETRY?.run(context(), "hunter2");
|
||||
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unlock a success when the server will not record it", async () => {
|
||||
// The document is already in the workbench, so a refused resolve is not a failed unlock.
|
||||
retryWithPassword.mockResolvedValue({
|
||||
ok: true,
|
||||
files: [{ blob: new Blob(["pdf"]), filename: "invoice.pdf" }],
|
||||
});
|
||||
reportNotificationResolved.mockResolvedValue(false);
|
||||
|
||||
expect(
|
||||
await registry().DECRYPT_AND_RETRY?.run(context(), "hunter2"),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("reports a failure when the unlocked document cannot be taken in", async () => {
|
||||
// Unlocked but dropped leaves the user with nothing, so it is never reported as success.
|
||||
retryWithPassword.mockResolvedValue({
|
||||
ok: true,
|
||||
files: [{ blob: new Blob(["pdf"]), filename: "invoice.pdf" }],
|
||||
});
|
||||
addFiles.mockRejectedValue(new Error("quota"));
|
||||
|
||||
const outcome = await registry().DECRYPT_AND_RETRY?.run(
|
||||
context(),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"The document was unlocked but could not be opened here. Try the tool directly.",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no unlock where there is nowhere to put the result", async () => {
|
||||
// No FileContext there, so an unlocked document would have nowhere to go.
|
||||
const actions = registry(inProcessor);
|
||||
|
||||
expect(actions.DECRYPT_AND_RETRY?.available(context())).toBe(false);
|
||||
expect(actions.VIEW_IN_PROCESSOR?.available(context())).toBe(true);
|
||||
// And it refuses rather than posting a password whose output would be discarded.
|
||||
expect(await actions.DECRYPT_AND_RETRY?.run(context(), "hunter2")).toEqual({
|
||||
ok: false,
|
||||
message: "This document can no longer be retried from this browser.",
|
||||
});
|
||||
expect(retryWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the unlock where the editor can take the result", () => {
|
||||
expect(registry().DECRYPT_AND_RETRY?.available(context())).toBe(true);
|
||||
expect(
|
||||
registry().DECRYPT_AND_RETRY?.available(context({ hasLocalFile: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("says it cannot hand the document over rather than navigating to nothing", async () => {
|
||||
// Spied on the prototype: jsdom's storage is a proxy, so an own-property spy does not take.
|
||||
const setItem = vi
|
||||
@@ -255,18 +618,339 @@ describe("useNotificationActions", () => {
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it("says so rather than posting nothing when the stash has gone", async () => {
|
||||
const outcome = await registry().DECRYPT_AND_RETRY?.run(
|
||||
context({ retryPayload: null }),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(retryWithPassword).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
message: "This document can no longer be retried from this browser.",
|
||||
});
|
||||
});
|
||||
|
||||
it("links to the recorded failures section of the processor", () => {
|
||||
registry().VIEW_IN_PROCESSOR?.run(context());
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith("/processor/documents#failures");
|
||||
});
|
||||
|
||||
it("navigates in place even with a loaded workbench, never opening a tab", () => {
|
||||
openFileIds = ["f-1"];
|
||||
const openTab = vi.spyOn(window, "open");
|
||||
|
||||
registry().VIEW_IN_PROCESSOR?.run(context());
|
||||
|
||||
expect(openTab).not.toHaveBeenCalled();
|
||||
expect(navigate).toHaveBeenCalledWith("/processor/documents#failures");
|
||||
openTab.mockRestore();
|
||||
});
|
||||
|
||||
it("navigates in place from the processor too", () => {
|
||||
const openTab = vi.spyOn(window, "open");
|
||||
|
||||
registry(inProcessor).VIEW_IN_PROCESSOR?.run(context());
|
||||
|
||||
expect(openTab).not.toHaveBeenCalled();
|
||||
expect(navigate).toHaveBeenCalledWith("/processor/documents#failures");
|
||||
openTab.mockRestore();
|
||||
});
|
||||
|
||||
it("offers the processor link whenever the server did", () => {
|
||||
// The server only sends it to someone it will let read the queue.
|
||||
expect(
|
||||
registry(inProcessor).VIEW_IN_PROCESSOR?.available(
|
||||
context({ hasLocalFile: false }),
|
||||
context({ hasLocalFile: false, retryPayload: null }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** The other retry shape: nothing is stashed, so everything comes off the row itself. */
|
||||
describe("retrying an attended policy run", () => {
|
||||
it("runs the policy again on the document it already holds", async () => {
|
||||
const outcome = await registry().RETRY?.run(policyContext());
|
||||
|
||||
expect(rerunPolicy).toHaveBeenCalledWith({
|
||||
policyId: "pol-1",
|
||||
fileId: "f-1",
|
||||
});
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
// Nothing was stashed for this row, so nothing may be read from one either.
|
||||
expect(retryWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-runs the policy rather than reopening a tool, even where a stash happens to exist", async () => {
|
||||
// An earlier tool failure may have left a stash, but the row is about the policy.
|
||||
await registry().RETRY?.run(
|
||||
policyContext({
|
||||
retryPayload: {
|
||||
operation: "removePassword",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
multiFile: false,
|
||||
errorCode: "E004",
|
||||
recordedAt: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(rerunPolicy).toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe("/");
|
||||
});
|
||||
|
||||
it("says the server refused rather than looking like it worked", async () => {
|
||||
rerunPolicy.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: "That policy is no longer enabled.",
|
||||
});
|
||||
|
||||
expect(await registry().RETRY?.run(policyContext())).toEqual({
|
||||
ok: false,
|
||||
message: "That policy is no longer enabled.",
|
||||
});
|
||||
});
|
||||
|
||||
it("has its own wording when the server refuses without any", async () => {
|
||||
rerunPolicy.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: null,
|
||||
});
|
||||
|
||||
expect(await registry().RETRY?.run(policyContext())).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"The policy could not be run again just now. Try again in a moment.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the document is gone rather than blaming the policy", async () => {
|
||||
rerunPolicy.mockResolvedValue({ ok: false, reason: "missingFile" });
|
||||
|
||||
expect(await registry().RETRY?.run(policyContext())).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
});
|
||||
});
|
||||
|
||||
it("is offered for an attended row whose document is here, and for nothing else", () => {
|
||||
const actions = registry();
|
||||
|
||||
expect(actions.RETRY?.available(policyContext())).toBe(true);
|
||||
expect(actions.DECRYPT_AND_RETRY?.available(policyContext())).toBe(true);
|
||||
|
||||
// Unattended: the fileId hashes a path that was never on any device, so nothing can re-submit.
|
||||
const unattended = policyContext({
|
||||
notification: notification({
|
||||
origin: "POLICY",
|
||||
policyId: "pol-1",
|
||||
sourceId: "src-1",
|
||||
}),
|
||||
});
|
||||
expect(actions.RETRY?.available(unattended)).toBe(false);
|
||||
expect(actions.DECRYPT_AND_RETRY?.available(unattended)).toBe(false);
|
||||
|
||||
// No policy named, and no stash either: nothing describes what would run again.
|
||||
expect(
|
||||
actions.RETRY?.available(
|
||||
policyContext({
|
||||
notification: notification({ origin: "POLICY", policyId: null }),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Document gone from this browser.
|
||||
expect(
|
||||
actions.RETRY?.available(policyContext({ hasLocalFile: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is offered nowhere without an editor to collect the result", () => {
|
||||
// The bell mounts outside the app's providers there, so a run has nowhere to land.
|
||||
const actions = registry(inProcessor);
|
||||
|
||||
expect(actions.RETRY?.available(policyContext())).toBe(false);
|
||||
expect(actions.DECRYPT_AND_RETRY?.available(policyContext())).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses rather than firing a run the processor shell could not collect", async () => {
|
||||
expect(await registry(inProcessor).RETRY?.run(policyContext())).toEqual({
|
||||
ok: false,
|
||||
message: "This document can no longer be retried from this browser.",
|
||||
});
|
||||
expect(rerunPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unlocks, takes the document in, runs the policy again, then closes the incident", async () => {
|
||||
const order: string[] = [];
|
||||
consumeFiles.mockImplementation(async () => {
|
||||
order.push("adopt");
|
||||
return ["f-unlocked"];
|
||||
});
|
||||
rechainPolicyOnDocument.mockImplementation(async () => {
|
||||
order.push("rerun");
|
||||
return { ok: true, tracked: true };
|
||||
});
|
||||
reportNotificationResolved.mockImplementation(async () => {
|
||||
order.push("resolve");
|
||||
return true;
|
||||
});
|
||||
|
||||
const outcome = await registry().DECRYPT_AND_RETRY?.run(
|
||||
policyContext(),
|
||||
"hunter2",
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
// The unlock is the remove-password call on the document the row names, not a stashed endpoint.
|
||||
expect(unlockLocalDocument).toHaveBeenCalledWith("f-1", "hunter2");
|
||||
// Versioned onto the encrypted original, so there is one document rather than two.
|
||||
expect(addFiles).not.toHaveBeenCalled();
|
||||
const [inputIds, files, stubs] = consumeFiles.mock.calls[0];
|
||||
expect(inputIds).toEqual(["f-1"]);
|
||||
expect((files as File[]).map((file) => file.name)).toEqual(["invoice.pdf"]);
|
||||
// derivedFromTool stops the adoption starting a SECOND, billed run of this same policy.
|
||||
expect(
|
||||
(stubs as Array<{ derivedFromTool?: boolean }>)[0].derivedFromTool,
|
||||
).toBe(true);
|
||||
// Under the ORIGINAL reference so a repeat folds on, with the output on the ADOPTED document.
|
||||
expect(rechainPolicyOnDocument).toHaveBeenCalledWith(
|
||||
{ policyId: "pol-1", fileId: "f-1" },
|
||||
expect.any(File),
|
||||
"f-unlocked",
|
||||
// No app-config above this render, so the engine reads as off and Classification drops out.
|
||||
false,
|
||||
);
|
||||
// And with the prefixed notification id, never a raw failure id.
|
||||
expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-1");
|
||||
expect(order).toEqual(["adopt", "rerun", "resolve"]);
|
||||
});
|
||||
|
||||
it("starts exactly one run for one click", async () => {
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2");
|
||||
|
||||
// One submission: the adoption's is silenced by derivedFromTool above.
|
||||
expect(rechainPolicyOnDocument).toHaveBeenCalledTimes(1);
|
||||
expect(rerunPolicy).not.toHaveBeenCalled();
|
||||
expect(consumeFiles.mock.calls[0][2][0]).toMatchObject({
|
||||
derivedFromTool: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still runs when the adoption reports no workspace id, rather than guessing one", async () => {
|
||||
// Nothing to attribute the output to, so the run goes untracked rather than to the original.
|
||||
consumeFiles.mockResolvedValue([]);
|
||||
rechainPolicyOnDocument.mockResolvedValue({ ok: true, tracked: false });
|
||||
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2");
|
||||
|
||||
expect(rechainPolicyOnDocument).toHaveBeenCalledWith(
|
||||
{ policyId: "pol-1", fileId: "f-1" },
|
||||
expect.any(File),
|
||||
null,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the row open when the re-run cannot deliver, and says why", async () => {
|
||||
// Untracked, so the processed document never arrives: the unlocked input is not the point.
|
||||
rechainPolicyOnDocument.mockResolvedValue({ ok: true, tracked: false });
|
||||
|
||||
expect(
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"The document was unlocked and the policy re-run started, but its result cannot be delivered here, so this failure stays open.",
|
||||
});
|
||||
// Adopted regardless: the password bought them the unlocked document either way.
|
||||
expect(consumeFiles).toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says an untracked plain re-run cannot be delivered either", async () => {
|
||||
// Same hole without a password: the cache could not place the policy, so nothing polls the run.
|
||||
rerunPolicy.mockResolvedValue({ ok: true, tracked: false });
|
||||
|
||||
expect(await registry().RETRY?.run(policyContext())).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"The policy re-run started, but its result cannot be delivered here, so this failure stays open.",
|
||||
});
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a wrong password for what it is, and touches nothing else", async () => {
|
||||
unlockLocalDocument.mockResolvedValue({
|
||||
ok: false,
|
||||
message: "The password is incorrect.",
|
||||
});
|
||||
|
||||
expect(
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "wrong"),
|
||||
).toEqual({ ok: false, message: "The password is incorrect." });
|
||||
expect(addFiles).not.toHaveBeenCalled();
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
// The row is still a failure, so nothing may report it fixed.
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("neither re-runs nor closes the incident when the document cannot be taken in", async () => {
|
||||
consumeFiles.mockRejectedValue(new Error("quota"));
|
||||
|
||||
expect(
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"The document was unlocked but could not be opened here. Try the tool directly.",
|
||||
});
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the unlock worked but the re-run did not, and leaves the row open", async () => {
|
||||
rechainPolicyOnDocument.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: "Queue full.",
|
||||
});
|
||||
|
||||
expect(
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2"),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
"The document was unlocked and opened here, but the policy could not be run on it again.",
|
||||
});
|
||||
// Adopted anyway: the password bought them the unlocked document, and that is theirs to keep.
|
||||
expect(consumeFiles).toHaveBeenCalled();
|
||||
// But nothing is fixed server-side, so the incident stays open.
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never hands the password to anything but the unlock", async () => {
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2");
|
||||
|
||||
// Everything downstream of the unlock: no payload, stash or id carries the password.
|
||||
const downstream = [
|
||||
...addFiles.mock.calls,
|
||||
...rechainPolicyOnDocument.mock.calls,
|
||||
...reportNotificationResolved.mock.calls,
|
||||
];
|
||||
expect(JSON.stringify(downstream)).not.toContain("hunter2");
|
||||
// Not in the file that goes back to the policy either: those are the server's unlocked bytes.
|
||||
const [, document] = rechainPolicyOnDocument.mock.calls[0] as [
|
||||
unknown,
|
||||
File,
|
||||
unknown,
|
||||
];
|
||||
expect(await bytesOf(document)).not.toContain("hunter2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,39 @@ import {
|
||||
} from "@app/contexts/file/contexts";
|
||||
import { NavigationActionsContext } from "@app/contexts/NavigationContext";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
PORTAL_BASENAME,
|
||||
PORTAL_FAILURES_ANCHOR,
|
||||
} from "@app/routes/portalBasename";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import {
|
||||
retryWithPassword,
|
||||
stashMatchesKind,
|
||||
unlockLocalDocument,
|
||||
type PasswordRetryOutcome,
|
||||
type RetryOutputFile,
|
||||
type RetryPayload,
|
||||
} from "@app/services/notificationRetry";
|
||||
import {
|
||||
rerunPolicy,
|
||||
rechainPolicyOnDocument,
|
||||
type PolicyRerunOutcome,
|
||||
type PolicyRetryTarget,
|
||||
} from "@app/services/notificationPolicyRetry";
|
||||
import { reportNotificationResolved } from "@app/services/notifications";
|
||||
import {
|
||||
createChildStub,
|
||||
generateProcessedFileMetadata,
|
||||
} from "@app/contexts/file/fileActions";
|
||||
import { isValidToolId, type ToolId } from "@app/types/toolId";
|
||||
import {
|
||||
createStirlingFile,
|
||||
type FileContextActions,
|
||||
type StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId, ToolOperation } from "@app/types/file";
|
||||
import {
|
||||
type ClientActionOutcome,
|
||||
type ClientActionRegistry,
|
||||
@@ -29,61 +55,148 @@ export {
|
||||
type NotificationActionContext,
|
||||
};
|
||||
|
||||
/**
|
||||
* The portal mounts as a sibling of `AppProviders`, so in the processor shell none of the workbench
|
||||
* contexts exist above this hook. That is why contexts are read raw and a document is handed over.
|
||||
*/
|
||||
// The portal mounts as a sibling of AppProviders, so no workbench contexts sit above this hook.
|
||||
|
||||
const HANDOFF_KEY = "stirling.notifications.pendingSelection";
|
||||
|
||||
const FAILURES_DESTINATION = `${PORTAL_BASENAME}/documents#${PORTAL_FAILURES_ANCHOR}`;
|
||||
|
||||
/** False when storage refused it: navigating anyway lands the user in an editor with nothing open. */
|
||||
function stashSelection(fileId: string): boolean {
|
||||
/** The document to open on arrival, and the tool to open it into. */
|
||||
interface Handoff {
|
||||
fileId: string;
|
||||
tool: ToolId | null;
|
||||
}
|
||||
|
||||
/** False when storage refused it: navigating anyway lands the user in an empty editor. */
|
||||
function stashSelection(fileId: string, tool: ToolId | null = null): boolean {
|
||||
try {
|
||||
window.sessionStorage.setItem(HANDOFF_KEY, fileId);
|
||||
window.sessionStorage.setItem(
|
||||
HANDOFF_KEY,
|
||||
JSON.stringify({ fileId, tool }),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function takeSelection(): string | null {
|
||||
function takeSelection(): Handoff | null {
|
||||
try {
|
||||
const fileId = window.sessionStorage.getItem(HANDOFF_KEY);
|
||||
if (fileId !== null) window.sessionStorage.removeItem(HANDOFF_KEY);
|
||||
return fileId;
|
||||
const stored = window.sessionStorage.getItem(HANDOFF_KEY);
|
||||
if (stored === null) return null;
|
||||
window.sessionStorage.removeItem(HANDOFF_KEY);
|
||||
const { fileId, tool } = JSON.parse(stored) as Record<string, unknown>;
|
||||
if (typeof fileId !== "string" || fileId === "") return null;
|
||||
return {
|
||||
fileId,
|
||||
tool: typeof tool === "string" && isValidToolId(tool) ? tool : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not the router's `navigate`: the editor reads its tool from the URL on mount and on a history pop,
|
||||
* and a router push is neither, so the address would change and the workbench would not.
|
||||
*/
|
||||
/** Not the router's `navigate`: the editor reads its tool on mount and on a history pop. */
|
||||
function goToEditor(path: string): void {
|
||||
window.history.pushState({}, "", withBasePath(path));
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
/** The stashed `params` stay stashed: `useBaseParameters` has no seam for initial values. */
|
||||
function toolOf(payload: RetryPayload): ToolId | null {
|
||||
return isValidToolId(payload.operation) ? payload.operation : null;
|
||||
}
|
||||
|
||||
/** A tool retry is an endpoint plus client-held parameters; a policy retry is a stored pair. */
|
||||
type RetryTarget =
|
||||
| { readonly kind: "tool"; readonly payload: RetryPayload }
|
||||
| { readonly kind: "policy"; readonly policy: PolicyRetryTarget };
|
||||
|
||||
/** Which of the two a notification describes, or null when nothing here can re-run it. */
|
||||
function retryTargetOf(context: NotificationActionContext): RetryTarget | null {
|
||||
const { notification, hasLocalFile, retryPayload } = context;
|
||||
if (!hasLocalFile) return null;
|
||||
|
||||
// The policy shape wins where it applies, being the more specific claim.
|
||||
const attended = (notification.sourceId ?? null) === null;
|
||||
if (attended && notification.policyId && notification.fileId) {
|
||||
return {
|
||||
kind: "policy",
|
||||
policy: { policyId: notification.policyId, fileId: notification.fileId },
|
||||
};
|
||||
}
|
||||
|
||||
// One stash per file, but one incident per kind per file, so the stash may be another row's.
|
||||
return retryPayload && stashMatchesKind(notification.kindId, retryPayload)
|
||||
? { kind: "tool", payload: retryPayload }
|
||||
: null;
|
||||
}
|
||||
|
||||
function asFiles(outputs: RetryOutputFile[]): File[] {
|
||||
return outputs.map(
|
||||
(output) =>
|
||||
new File([output.blob], output.filename, {
|
||||
type: output.blob.type || "application/pdf",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Versions the original in place; `derivedFromTool` keeps `usePolicyAutoRun` off the result. */
|
||||
async function adopt(
|
||||
actions: FileContextActions,
|
||||
parentStub: StirlingFileStub | null,
|
||||
files: File[],
|
||||
): Promise<FileId[]> {
|
||||
const unlocked = files[0];
|
||||
if (!unlocked) return [];
|
||||
|
||||
if (!parentStub) {
|
||||
const added = await actions.addFiles([unlocked], {
|
||||
selectFiles: true,
|
||||
derivedFromTool: true,
|
||||
});
|
||||
return added.map((file) => file.fileId);
|
||||
}
|
||||
|
||||
const metadata = await generateProcessedFileMetadata(unlocked);
|
||||
const operation: ToolOperation = {
|
||||
toolId: "removePassword",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const childStub: StirlingFileStub = {
|
||||
...createChildStub(
|
||||
parentStub,
|
||||
operation,
|
||||
unlocked,
|
||||
metadata?.thumbnailUrl,
|
||||
metadata,
|
||||
),
|
||||
derivedFromTool: true,
|
||||
};
|
||||
const stirlingFile = createStirlingFile(unlocked, childStub.id);
|
||||
const outputIds = await actions.consumeFiles(
|
||||
[parentStub.id],
|
||||
[stirlingFile],
|
||||
[childStub],
|
||||
);
|
||||
actions.setSelectedFiles(outputIds);
|
||||
return outputIds;
|
||||
}
|
||||
|
||||
export function useNotificationActions(): ClientActionRegistry {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// Raw, because the hooks that wrap these throw when there is no provider, and in the processor
|
||||
// shell there is none. All four are present together or not at all.
|
||||
// Raw, because the wrapping hooks throw without a provider. All four are present or none are.
|
||||
const fileContext = useContext(FileActionsContext);
|
||||
const fileStore = useContext(FileStoreContext);
|
||||
const navigation = useContext(NavigationActionsContext);
|
||||
const viewer = useContext(ViewerContext);
|
||||
const canOpenHere = Boolean(fileContext && fileStore && navigation && viewer);
|
||||
// The upload chain a retry rejoins excludes Classification when the engine is off.
|
||||
|
||||
/**
|
||||
* Opens the way the file sidebar does. Selecting alone shows nothing: an id the workbench does not
|
||||
* hold has nothing to render, and the workbench keeps whatever view it was on.
|
||||
*/
|
||||
/** Opens the way the file sidebar does: an id the workbench does not hold renders nothing. */
|
||||
const openInWorkbench = useCallback(
|
||||
async (fileId: string): Promise<boolean> => {
|
||||
async (fileId: string, tool: ToolId | null = null): Promise<boolean> => {
|
||||
if (!fileContext || !fileStore || !navigation || !viewer) return false;
|
||||
|
||||
const stub = await fileStorage.getStirlingFileStub(fileId as FileId);
|
||||
@@ -96,17 +209,22 @@ export function useNotificationActions(): ClientActionRegistry {
|
||||
await fileContext.actions.addStirlingFileStubs([stub]);
|
||||
}
|
||||
viewer.setActiveFileId(fileId);
|
||||
navigation.actions.setWorkbench("viewer");
|
||||
// The viewer is what scopes a tool to one document; every other view hands it all of them.
|
||||
if (tool) {
|
||||
navigation.actions.setToolAndWorkbench(tool, "viewer");
|
||||
} else {
|
||||
navigation.actions.setWorkbench("viewer");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[fileContext, fileStore, navigation, viewer],
|
||||
);
|
||||
|
||||
// One-shot: read and cleared, so a later render cannot reopen a file the user has moved on from.
|
||||
// One-shot: a later render must not reopen a file the user has moved on from.
|
||||
useEffect(() => {
|
||||
if (!canOpenHere) return;
|
||||
const fileId = takeSelection();
|
||||
if (fileId) void openInWorkbench(fileId);
|
||||
const handoff = takeSelection();
|
||||
if (handoff) void openInWorkbench(handoff.fileId, handoff.tool);
|
||||
}, [canOpenHere, openInWorkbench]);
|
||||
|
||||
return useMemo<ClientActionRegistry>(() => {
|
||||
@@ -115,8 +233,7 @@ export function useNotificationActions(): ClientActionRegistry {
|
||||
): Promise<ClientActionOutcome | void> => {
|
||||
if (!fileId) return;
|
||||
|
||||
// In place, with no navigation: "/" is the role-based router, so going there reads as the app
|
||||
// reloading and lands the user wherever their role says rather than on their document.
|
||||
// In place: "/" is the role-based router, which lands the user wherever their role says.
|
||||
if (canOpenHere) {
|
||||
return (await openInWorkbench(fileId)) ? undefined : { ok: false };
|
||||
}
|
||||
@@ -133,6 +250,196 @@ export function useNotificationActions(): ClientActionRegistry {
|
||||
goToEditor(EDITOR_BASENAME);
|
||||
};
|
||||
|
||||
/** Into the viewer, the only view that scopes the tool to the one document that failed. */
|
||||
const openToolWithDocument = async (
|
||||
fileId: string | null,
|
||||
tool: ToolId | null,
|
||||
): Promise<ClientActionOutcome | void> => {
|
||||
if (canOpenHere && fileId) {
|
||||
return (await openInWorkbench(fileId, tool))
|
||||
? undefined
|
||||
: unavailable();
|
||||
}
|
||||
if (fileId && !stashSelection(fileId, tool)) {
|
||||
// Nothing would be open on arrival, so say so rather than navigate regardless.
|
||||
return {
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.handoffUnavailable",
|
||||
"This browser will not let the processor pass the document to the editor. Open it from the editor instead.",
|
||||
),
|
||||
};
|
||||
}
|
||||
goToEditor(tool ? getToolUrlPath(tool) : EDITOR_BASENAME);
|
||||
};
|
||||
|
||||
const unavailable = (): ClientActionOutcome => ({
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.retryUnavailable",
|
||||
"This document can no longer be retried from this browser.",
|
||||
),
|
||||
});
|
||||
|
||||
/** The service reports why and this layer words it, because the wording belongs where `t` is. */
|
||||
const unlockFailure = (
|
||||
outcome: PasswordRetryOutcome,
|
||||
): ClientActionOutcome => {
|
||||
if (outcome.reason === "fileMissing") {
|
||||
return {
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.notOnThisDevice",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (outcome.reason === "notRetryable") return unavailable();
|
||||
// The server's own words, or nothing: the row falls back to its generic failure line.
|
||||
return { ok: false, message: outcome.message ?? undefined };
|
||||
};
|
||||
|
||||
/** An untracked run is a failure on purpose: nothing here will collect what it produces. */
|
||||
const rerunOutcome = (
|
||||
outcome: PolicyRerunOutcome,
|
||||
adopted: boolean,
|
||||
): ClientActionOutcome => {
|
||||
if (outcome.ok && outcome.tracked) return { ok: true };
|
||||
if (outcome.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
message: adopted
|
||||
? t(
|
||||
"notifications.unlockedRerunUndelivered",
|
||||
"The document was unlocked and the policy re-run started, but its result cannot be delivered here, so this failure stays open.",
|
||||
)
|
||||
: t(
|
||||
"notifications.rerunUndelivered",
|
||||
"The policy re-run started, but its result cannot be delivered here, so this failure stays open.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (outcome.reason === "missingFile") {
|
||||
return {
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.notOnThisDevice",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (adopted) {
|
||||
return {
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.unlockedNotRerun",
|
||||
"The document was unlocked and opened here, but the policy could not be run on it again.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
outcome.message ??
|
||||
t(
|
||||
"notifications.rerunRejected",
|
||||
"The policy could not be run again just now. Try again in a moment.",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** A policy re-run also needs the editor's providers, to collect its output. */
|
||||
const canRetry = (context: NotificationActionContext): boolean => {
|
||||
const target = retryTargetOf(context);
|
||||
if (!target) return false;
|
||||
return target.kind === "tool" || fileContext !== undefined;
|
||||
};
|
||||
|
||||
const retry: ClientActionSpec = {
|
||||
available: canRetry,
|
||||
closesPanel: true,
|
||||
run: async (context): Promise<ClientActionOutcome | void> => {
|
||||
const target = retryTargetOf(context);
|
||||
if (!target) return unavailable();
|
||||
|
||||
// A tool opens rather than re-runs: it failed once, so the user sees the settings first.
|
||||
if (target.kind === "tool") {
|
||||
return openToolWithDocument(
|
||||
context.notification.fileId,
|
||||
toolOf(target.payload),
|
||||
);
|
||||
}
|
||||
if (!fileContext) return unavailable();
|
||||
return rerunOutcome(await rerunPolicy(target.policy), false);
|
||||
},
|
||||
};
|
||||
|
||||
const decryptAndRetry: ClientActionSpec = {
|
||||
// In the processor shell an unlocked document has nowhere to go, so the row promotes on.
|
||||
available: (context) => fileContext !== undefined && canRetry(context),
|
||||
needsPassword: true,
|
||||
// On success the adopted document is the destination, and it is behind the panel.
|
||||
closesPanel: true,
|
||||
run: async (context, password): Promise<ClientActionOutcome> => {
|
||||
const target = retryTargetOf(context);
|
||||
if (!target || !password || !fileContext) return unavailable();
|
||||
|
||||
// The stash knows a tool's parameters; a locked policy input just needs unlocking.
|
||||
const outcome =
|
||||
target.kind === "tool"
|
||||
? await retryWithPassword(
|
||||
target.payload,
|
||||
password,
|
||||
context.notification.fileId,
|
||||
)
|
||||
: await unlockLocalDocument(target.policy.fileId, password);
|
||||
if (!outcome.ok) return unlockFailure(outcome);
|
||||
|
||||
// A failed adoption fails the action: dropping the result leaves them nothing.
|
||||
const unlocked = asFiles(outcome.files ?? []);
|
||||
// Only a policy names an original to version; a tool retry keeps adding its output.
|
||||
const originalId =
|
||||
target.kind === "policy" ? (target.policy.fileId as FileId) : null;
|
||||
// Storage too, or a file merely closed in the sidebar gets decrypted twice over.
|
||||
const parentStub = originalId
|
||||
? (fileStore?.getState().files.byId?.[originalId] ??
|
||||
(await fileStorage.getStirlingFileStub(originalId)) ??
|
||||
null)
|
||||
: null;
|
||||
let adopted: FileId[] = [];
|
||||
try {
|
||||
adopted = await adopt(fileContext.actions, parentStub, unlocked);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.adoptFailed",
|
||||
"The document was unlocked but could not be opened here. Try the tool directly.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Under the ORIGINAL reference so a repeat folds on, and after the adoption.
|
||||
if (target.kind === "policy") {
|
||||
const document = unlocked[0];
|
||||
const rerun: PolicyRerunOutcome = document
|
||||
? await rechainPolicyOnDocument(
|
||||
target.policy,
|
||||
document,
|
||||
adopted[0] ?? null,
|
||||
)
|
||||
: { ok: false, reason: "missingFile" };
|
||||
// Anything short of a tracked run stops here: the input alone is not the result.
|
||||
const result = rerunOutcome(rerun, true);
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
|
||||
// Ignored on purpose: a refused resolve is not a failed unlock.
|
||||
await reportNotificationResolved(context.notification.id);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const viewFile: ClientActionSpec = {
|
||||
available: (context) => context.hasLocalFile,
|
||||
closesPanel: true,
|
||||
@@ -140,16 +447,24 @@ export function useNotificationActions(): ClientActionRegistry {
|
||||
};
|
||||
|
||||
const viewInProcessor: ClientActionSpec = {
|
||||
// Its destination is dev-only until failures get a review screen; the other half of this gate
|
||||
// is in portal/views/Documents, and both lift together.
|
||||
// Dev-only until failures get a review screen; portal/views/Documents holds the other half.
|
||||
available: () => import.meta.env.DEV,
|
||||
closesPanel: true,
|
||||
run: () => navigate(FAILURES_DESTINATION),
|
||||
};
|
||||
|
||||
return {
|
||||
RETRY: retry,
|
||||
DECRYPT_AND_RETRY: decryptAndRetry,
|
||||
VIEW_FILE: viewFile,
|
||||
VIEW_IN_PROCESSOR: viewInProcessor,
|
||||
};
|
||||
}, [canOpenHere, openInWorkbench, navigate, t]);
|
||||
}, [
|
||||
canOpenHere,
|
||||
openInWorkbench,
|
||||
fileContext,
|
||||
fileStore,
|
||||
navigate,
|
||||
t,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
appliedCategoriesFor,
|
||||
dispatchKey,
|
||||
getRun,
|
||||
isDispatched,
|
||||
@@ -83,6 +84,100 @@ describe("policyRunStore", () => {
|
||||
expect(isDispatched("security", "f1")).toBe(true);
|
||||
});
|
||||
|
||||
it("a browser-local run does not claim the (policy, file) dispatch key", () => {
|
||||
// The local classification heuristic records a run for the same (classification, file) pair
|
||||
// the server escalation is keyed on. If that claimed the key, the auto-run would read
|
||||
// "already dispatched" and never ask the AI - which killed escalation entirely.
|
||||
recordRunStart(
|
||||
rec({
|
||||
runId: "local-classification-f1-1",
|
||||
categoryId: "classification",
|
||||
fileId: "f1",
|
||||
target: "local",
|
||||
browserLocal: true,
|
||||
status: "RUNNING",
|
||||
}),
|
||||
);
|
||||
expect(getRun("local-classification-f1-1")).toBeDefined();
|
||||
expect(isDispatched("classification", "f1")).toBe(false);
|
||||
});
|
||||
|
||||
it("a real backend run still claims the dispatch key", () => {
|
||||
recordRunStart(rec({ runId: "srv-1", categoryId: "classification" }));
|
||||
expect(isDispatched("classification", "f1")).toBe(true);
|
||||
});
|
||||
|
||||
describe("appliedCategoriesFor", () => {
|
||||
it("walks a rewriting chain back to the uploaded document", () => {
|
||||
recordRunStart(
|
||||
rec({ runId: "r-w", categoryId: "watermark", fileId: "f1" }),
|
||||
);
|
||||
updateRun("r-w", { status: "COMPLETED", outputFileIds: ["f2"] });
|
||||
recordRunStart(
|
||||
rec({ runId: "r-s", categoryId: "security", fileId: "f2" }),
|
||||
);
|
||||
updateRun("r-s", { status: "COMPLETED", outputFileIds: ["f3"] });
|
||||
|
||||
expect([...appliedCategoriesFor("f3")].sort()).toEqual([
|
||||
"security",
|
||||
"watermark",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts an annotating run, which names its input as its own output", () => {
|
||||
recordRunStart(
|
||||
rec({ runId: "r-c", categoryId: "classification", fileId: "f1" }),
|
||||
);
|
||||
updateRun("r-c", { status: "COMPLETED", outputFileIds: ["f1"] });
|
||||
|
||||
expect([...appliedCategoriesFor("f1")]).toEqual(["classification"]);
|
||||
});
|
||||
|
||||
it("does not count a browser-local pass as the policy having run", () => {
|
||||
// The local heuristic settles COMPLETED with the input as its own output. Counting it
|
||||
// would make a retry skip classification, killing the escalation #7667 restored.
|
||||
recordRunStart(
|
||||
rec({
|
||||
runId: "local-c",
|
||||
categoryId: "classification",
|
||||
fileId: "f1",
|
||||
target: "local",
|
||||
browserLocal: true,
|
||||
}),
|
||||
);
|
||||
updateRun("local-c", { status: "COMPLETED", outputFileIds: ["f1"] });
|
||||
|
||||
expect(appliedCategoriesFor("f1").size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps climbing past an annotating run rather than stalling on it", () => {
|
||||
// The annotating run's output IS its input, so the walk must not treat that as a
|
||||
// lineage step - otherwise the cursor never moves and earlier policies are missed.
|
||||
recordRunStart(
|
||||
rec({ runId: "r-w", categoryId: "watermark", fileId: "f1" }),
|
||||
);
|
||||
updateRun("r-w", { status: "COMPLETED", outputFileIds: ["f2"] });
|
||||
recordRunStart(
|
||||
rec({ runId: "r-c", categoryId: "classification", fileId: "f2" }),
|
||||
);
|
||||
updateRun("r-c", { status: "COMPLETED", outputFileIds: ["f2"] });
|
||||
|
||||
expect([...appliedCategoriesFor("f2")].sort()).toEqual([
|
||||
"classification",
|
||||
"watermark",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a run that failed, so it stays eligible to run again", () => {
|
||||
recordRunStart(
|
||||
rec({ runId: "r-f", categoryId: "security", fileId: "f1" }),
|
||||
);
|
||||
updateRun("r-f", { status: "FAILED", outputFileIds: ["f2"] });
|
||||
|
||||
expect(appliedCategoriesFor("f2").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("never evicts in-flight runs, even past the soft cap", () => {
|
||||
// A large upload batch can exceed the cap while still processing. Dropping a
|
||||
// live run would orphan its polling/import and undercount progress, so every
|
||||
|
||||
@@ -211,6 +211,27 @@ export function isDispatched(categoryId: string, fileId: string): boolean {
|
||||
return state.dispatched.includes(dispatchKey(categoryId, fileId));
|
||||
}
|
||||
|
||||
/** Walked back through this document's lineage. Only a COMPLETED server run counts as applied. */
|
||||
export function appliedCategoriesFor(fileId: string): Set<string> {
|
||||
const applied = new Set<string>();
|
||||
let cursor: string | null = fileId;
|
||||
// A lineage cannot outrun the recorded runs, and the bound also breaks a hand-edited cycle.
|
||||
for (let step = 0; step < state.runs.length; step++) {
|
||||
if (cursor === null) break;
|
||||
const child: string = cursor;
|
||||
cursor = null;
|
||||
for (const run of state.runs) {
|
||||
// A local first pass is not the policy's run: counting it would skip the escalation.
|
||||
if (run.status !== "COMPLETED" || run.browserLocal) continue;
|
||||
if (!(run.outputFileIds ?? []).includes(child)) continue;
|
||||
applied.add(run.categoryId);
|
||||
// An annotating run names its input as its own output, so it adds no lineage step.
|
||||
if (run.fileId !== child) cursor = run.fileId;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Record a newly-dispatched run (marks it dispatched + adds the record). */
|
||||
export function recordRunStart(record: PolicyRunRecord) {
|
||||
const key = dispatchKey(record.categoryId, record.fileId);
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* An encrypted upload opens the unlock prompt and dispatches its policy in the same tick, so the
|
||||
* two race. If the user wins, the run fails on a document they have already replaced, billing for
|
||||
* it and leaving a row about a version that no longer exists. The dispatch waits for the answer.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("@app/hooks/useAiEngineEnabled", () => ({
|
||||
useAiEngineEnabled: () => false,
|
||||
}));
|
||||
const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = [];
|
||||
vi.mock("@app/contexts/FileContext", () => ({
|
||||
useAllFiles: () => ({ fileStubs }),
|
||||
useFileManagement: () => ({ addFiles: vi.fn() }),
|
||||
useFileContext: () => ({ consumeFiles: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@app/hooks/usePolicies", () => ({
|
||||
usePolicies: () => ({
|
||||
policies: {
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-sec",
|
||||
runOn: "upload",
|
||||
order: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
|
||||
}));
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({ bumpRevision: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import { resetPolicyRuns } from "@app/components/policies/policyRunStore";
|
||||
import { setPendingUnlocks } from "@app/services/pendingUnlocks";
|
||||
import { runStoredPolicy } from "@app/services/policyApi";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
|
||||
const runStored = vi.mocked(runStoredPolicy);
|
||||
const getFile = vi.mocked(fileStorage.getStirlingFile);
|
||||
|
||||
function setFileStubs(next: typeof fileStubs) {
|
||||
fileStubs.length = 0;
|
||||
fileStubs.push(...next);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetPolicyRuns();
|
||||
setPendingUnlocks([]);
|
||||
runStored.mockReset().mockResolvedValue("run-sec");
|
||||
getFile.mockReset().mockResolvedValue({ size: 1460 } as never);
|
||||
setFileStubs([]);
|
||||
});
|
||||
// Cleared in beforeEach, not here: notifying a component RTL has not unmounted yet
|
||||
// would be a state update outside act.
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
}
|
||||
|
||||
describe("an upload waiting on its unlock prompt", () => {
|
||||
it("holds the run back while the prompt is open", async () => {
|
||||
setPendingUnlocks(["file-locked"]);
|
||||
setFileStubs([{ id: "file-locked", name: "locked.pdf" }]);
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await settle();
|
||||
|
||||
expect(runStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs once the prompt is answered, so skipping still records the failure", async () => {
|
||||
// Skipping releases the file encrypted: the run fails, and that failure is the row the
|
||||
// bell offers Decrypt and retry on. Holding it back forever would lose that entirely.
|
||||
setPendingUnlocks(["file-locked"]);
|
||||
setFileStubs([{ id: "file-locked", name: "locked.pdf" }]);
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await settle();
|
||||
expect(runStored).not.toHaveBeenCalled();
|
||||
|
||||
act(() => setPendingUnlocks([]));
|
||||
await settle();
|
||||
|
||||
expect(runStored).toHaveBeenCalledWith(
|
||||
"backend-sec",
|
||||
expect.anything(),
|
||||
"file-locked",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an upload nobody is prompting about alone", async () => {
|
||||
setFileStubs([{ id: "file-plain", name: "plain.pdf" }]);
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await settle();
|
||||
|
||||
expect(runStored).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,13 @@
|
||||
* Policies sharing a trigger run as an ordered chain so their effects accumulate.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
useAllFiles,
|
||||
useFileManagement,
|
||||
@@ -11,6 +17,11 @@ import {
|
||||
} from "@app/contexts/FileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { refreshNotificationsNow } from "@app/hooks/useNotifications";
|
||||
import {
|
||||
isAwaitingUnlock,
|
||||
pendingUnlocksVersion,
|
||||
subscribeToPendingUnlocks,
|
||||
} from "@app/services/pendingUnlocks";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import i18n from "@app/i18n";
|
||||
import {
|
||||
@@ -152,6 +163,14 @@ export function usePolicyAutoRun(): void {
|
||||
// Chain-continuations handled this session, so the next policy fires once per run.
|
||||
const chained = useRef<Set<string>>(new Set());
|
||||
|
||||
// Answering a prompt has to re-run the dispatch effect, or a released file waits for the
|
||||
// next unrelated render to be picked up.
|
||||
const unlocksVersion = useSyncExternalStore(
|
||||
subscribeToPendingUnlocks,
|
||||
pendingUnlocksVersion,
|
||||
pendingUnlocksVersion,
|
||||
);
|
||||
|
||||
// Latest policies, read from inside the stable retry callback (which has no deps).
|
||||
const policiesRef = useRef(policies);
|
||||
policiesRef.current = policies;
|
||||
@@ -226,6 +245,10 @@ export function usePolicyAutoRun(): void {
|
||||
// Input-mode policies cover uploads only; tool-produced files are left to
|
||||
// export-mode policies at export time.
|
||||
if (stub.derivedFromTool) continue;
|
||||
// Held while the unlock prompt is open: the run would fail on a document the user is
|
||||
// about to decrypt, bill for it, and leave a row about a version soon replaced. Skipping
|
||||
// the prompt releases it, so a document nobody unlocks still records its failure.
|
||||
if (isAwaitingUnlock(stub.id)) continue;
|
||||
const key = dispatchKey(firstCategory, stub.id);
|
||||
// Skip if already run (persisted) or in flight - the in-memory guard covers the async wait.
|
||||
if (
|
||||
@@ -241,7 +264,7 @@ export function usePolicyAutoRun(): void {
|
||||
})
|
||||
.finally(() => dispatching.current.delete(key));
|
||||
}
|
||||
}, [fileStubs, policies, orderedUploadCategories]);
|
||||
}, [fileStubs, policies, orderedUploadCategories, unlocksVersion]);
|
||||
|
||||
// Once a run's output lands, fire the next upload policy on it - success only, once per
|
||||
// run. isDispatched guards re-dispatch across reloads.
|
||||
@@ -415,16 +438,6 @@ function applyOutputName(
|
||||
: `${base}_${outputName}${ext}`;
|
||||
}
|
||||
|
||||
/** Next upload policy in the chain, or undefined if last or no longer eligible. */
|
||||
function nextUploadCategory(
|
||||
orderedUploadCategories: string[],
|
||||
categoryId: string,
|
||||
): string | undefined {
|
||||
const index = orderedUploadCategories.indexOf(categoryId);
|
||||
if (index < 0) return undefined;
|
||||
return orderedUploadCategories[index + 1];
|
||||
}
|
||||
|
||||
async function reconcileServerRuns(
|
||||
policies: PoliciesByCategory,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
} from "@app/services/notifications";
|
||||
import type { SucceededToolRun } from "@app/hooks/tools/shared/useResolutionContinuation";
|
||||
|
||||
// A manual run that IS an open failure's fix closes the row, under the rules the bell obeys.
|
||||
|
||||
const fetchNotifications = vi.fn();
|
||||
const reportNotificationResolved = vi.fn();
|
||||
vi.mock("@app/services/notifications", () => ({
|
||||
fetchNotifications: (...args: unknown[]) => fetchNotifications(...args),
|
||||
reportNotificationResolved: (...args: unknown[]) =>
|
||||
reportNotificationResolved(...args),
|
||||
}));
|
||||
|
||||
const refreshNotificationsNow = vi.fn();
|
||||
vi.mock("@app/hooks/useNotifications", () => ({
|
||||
refreshNotificationsNow: () => refreshNotificationsNow(),
|
||||
}));
|
||||
|
||||
const loadRetryPayload = vi.fn();
|
||||
vi.mock("@app/services/notificationRetry", async (importOriginal) => ({
|
||||
// The real stashMatchesKind: pure, and part of the behaviour under test.
|
||||
...(await importOriginal<typeof import("@app/services/notificationRetry")>()),
|
||||
loadRetryPayload: (fileId: string) => loadRetryPayload(fileId),
|
||||
}));
|
||||
|
||||
const rechainPolicyOnDocument = vi.fn();
|
||||
vi.mock("@app/services/notificationPolicyRetry", () => ({
|
||||
rechainPolicyOnDocument: (...args: unknown[]) =>
|
||||
rechainPolicyOnDocument(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@app/hooks/useAiEngineEnabled", () => ({
|
||||
useAiEngineEnabled: () => true,
|
||||
}));
|
||||
|
||||
const { useResolutionContinuation } =
|
||||
await import("@app/hooks/tools/shared/useResolutionContinuation");
|
||||
|
||||
function offer(
|
||||
id: string,
|
||||
slot: NotificationActionOffer["slot"],
|
||||
enabled = true,
|
||||
): NotificationActionOffer {
|
||||
return {
|
||||
id,
|
||||
labelKey: `portal.failures.action.${id.toLowerCase()}`,
|
||||
defaultLabel: id,
|
||||
slot,
|
||||
enabled,
|
||||
disabledReasonKey: enabled ? null : "portal.failures.disabled.closed",
|
||||
};
|
||||
}
|
||||
|
||||
/** An attended policy failure of the reader's own, with the fix on offer. */
|
||||
function policyRow(overrides: Partial<AppNotification> = {}): AppNotification {
|
||||
return {
|
||||
id: "failure:evt-1",
|
||||
source: "FAILURE",
|
||||
kindId: "INPUT_PASSWORD_PROTECTED",
|
||||
origin: "POLICY",
|
||||
ownership: "MINE",
|
||||
severity: "ERROR",
|
||||
status: "NEW",
|
||||
titleKey: "portal.failures.kind.inputPasswordProtected.title",
|
||||
defaultTitle: "Password-protected document",
|
||||
detail: "The PDF Document is passworded",
|
||||
fileId: "f-locked",
|
||||
sourceId: null,
|
||||
policyId: "pol-1",
|
||||
occurrences: 1,
|
||||
createdAt: "2026-08-06T00:00:00Z",
|
||||
lastSeenAt: "2026-08-06T00:00:00Z",
|
||||
actions: [
|
||||
offer("DECRYPT_AND_RETRY", "RESOLUTION"),
|
||||
offer("RETRY", "OVERFLOW"),
|
||||
offer("VIEW_FILE", "OVERFLOW"),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A tool failure of the reader's own, with its retry on offer. */
|
||||
function toolRow(overrides: Partial<AppNotification> = {}): AppNotification {
|
||||
return policyRow({
|
||||
id: "failure:evt-2",
|
||||
kindId: "UNKNOWN",
|
||||
origin: "TOOL",
|
||||
policyId: null,
|
||||
fileId: "f-tool",
|
||||
actions: [offer("RETRY", "SECONDARY"), offer("VIEW_FILE", "OVERFLOW")],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function unlockRun(
|
||||
overrides: Partial<SucceededToolRun> = {},
|
||||
): SucceededToolRun {
|
||||
return {
|
||||
operation: "removePassword",
|
||||
inputFileIds: ["f-locked"],
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["pdf"], "unlocked.pdf", { type: "application/pdf" }),
|
||||
fileId: "f-unlocked",
|
||||
sourceFileId: "f-locked",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function continuation() {
|
||||
return renderHook(() => useResolutionContinuation()).result.current;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchNotifications.mockReset().mockResolvedValue({
|
||||
notifications: [],
|
||||
viewerReviewsTeam: true,
|
||||
});
|
||||
reportNotificationResolved.mockReset().mockResolvedValue(true);
|
||||
refreshNotificationsNow.mockReset();
|
||||
loadRetryPayload.mockReset().mockResolvedValue(null);
|
||||
rechainPolicyOnDocument
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, tracked: true });
|
||||
});
|
||||
|
||||
describe("useResolutionContinuation", () => {
|
||||
it("carries a manual unlock through the failed policy and closes the row", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
|
||||
continuation()(unlockRun());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-1"),
|
||||
);
|
||||
// The ORIGINAL reference so a repeat folds on, attributed to the run's own output.
|
||||
expect(rechainPolicyOnDocument).toHaveBeenCalledWith(
|
||||
{ policyId: "pol-1", fileId: "f-locked" },
|
||||
expect.any(File),
|
||||
"f-unlocked",
|
||||
true,
|
||||
);
|
||||
expect(refreshNotificationsNow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("costs no read at all for a run that could not resolve anything", async () => {
|
||||
continuation()({
|
||||
operation: "compress",
|
||||
inputFileIds: ["f-1"],
|
||||
outputs: [],
|
||||
});
|
||||
|
||||
// loadRetryPayload settles first, so give the async path a beat to prove the negative.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(fetchNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the row open when the re-run cannot be delivered", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
rechainPolicyOnDocument.mockResolvedValue({ ok: true, tracked: false });
|
||||
|
||||
continuation()(unlockRun());
|
||||
|
||||
await waitFor(() => expect(rechainPolicyOnDocument).toHaveBeenCalled());
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
expect(refreshNotificationsNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the row open when the server refuses the re-run", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
rechainPolicyOnDocument.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: "gone",
|
||||
});
|
||||
|
||||
continuation()(unlockRun());
|
||||
|
||||
await waitFor(() => expect(rechainPolicyOnDocument).toHaveBeenCalled());
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects a withheld resolution: the server said no, however the fix arrived", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [
|
||||
policyRow({
|
||||
actions: [
|
||||
offer("DECRYPT_AND_RETRY", "RESOLUTION", false),
|
||||
offer("VIEW_IN_PROCESSOR", "SECONDARY"),
|
||||
],
|
||||
}),
|
||||
],
|
||||
viewerReviewsTeam: true,
|
||||
});
|
||||
|
||||
continuation()(unlockRun());
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never closes a colleague's incident, even having fixed their document", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow({ ownership: "THEIRS" })],
|
||||
viewerReviewsTeam: true,
|
||||
});
|
||||
|
||||
continuation()(unlockRun());
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves an unattended row alone: its reference was never this browser's", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow({ sourceId: "src-1" })],
|
||||
viewerReviewsTeam: true,
|
||||
});
|
||||
|
||||
continuation()(unlockRun());
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a row whose output it cannot name rather than guessing one", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
|
||||
// Two inputs, two outputs, no provenance: which is the unlocked document is a guess.
|
||||
continuation()(
|
||||
unlockRun({
|
||||
inputFileIds: ["f-locked", "f-other"],
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["a"], "a.pdf"),
|
||||
fileId: "out-1",
|
||||
sourceFileId: null,
|
||||
},
|
||||
{
|
||||
file: new File(["b"], "b.pdf"),
|
||||
fileId: "out-2",
|
||||
sourceFileId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pairs by provenance when a run versioned several inputs at once", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
|
||||
continuation()(
|
||||
unlockRun({
|
||||
inputFileIds: ["f-other", "f-locked"],
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["a"], "a.pdf"),
|
||||
fileId: "out-other",
|
||||
sourceFileId: "f-other",
|
||||
},
|
||||
{
|
||||
file: new File(["b"], "b.pdf"),
|
||||
fileId: "out-locked",
|
||||
sourceFileId: "f-locked",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(rechainPolicyOnDocument).toHaveBeenCalled());
|
||||
expect(rechainPolicyOnDocument.mock.calls[0][2]).toBe("out-locked");
|
||||
});
|
||||
|
||||
it("resolves a tool failure when the operation that failed succeeds on its document", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [toolRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
loadRetryPayload.mockResolvedValue({
|
||||
operation: "compress",
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
params: {},
|
||||
fileIds: ["f-tool"],
|
||||
recordedAt: 0,
|
||||
});
|
||||
|
||||
continuation()({
|
||||
operation: "compress",
|
||||
inputFileIds: ["f-tool"],
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["pdf"], "smaller.pdf"),
|
||||
fileId: "f-out",
|
||||
sourceFileId: "f-tool",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-2"),
|
||||
);
|
||||
// Nothing further to run: the failed operation itself is what just succeeded.
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(refreshNotificationsNow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-runs nothing from a success that is not the row's declared resolution", async () => {
|
||||
// The failure wants an unlock; a compress on the same document fixes nothing it is about.
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [policyRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
// The stash lets it past the local gate, so the kind check is what refuses it.
|
||||
loadRetryPayload.mockResolvedValue({
|
||||
operation: "compress",
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
params: {},
|
||||
fileIds: ["f-locked"],
|
||||
recordedAt: 0,
|
||||
});
|
||||
|
||||
continuation()({
|
||||
operation: "compress",
|
||||
inputFileIds: ["f-locked"],
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["pdf"], "smaller.pdf"),
|
||||
fileId: "f-out",
|
||||
sourceFileId: "f-locked",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(rechainPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve a file its batch run failed for", async () => {
|
||||
// Being an input of a successful run proves nothing; producing an output does.
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [toolRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
loadRetryPayload.mockResolvedValue({
|
||||
operation: "compress",
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
params: {},
|
||||
fileIds: ["f-tool", "f-other"],
|
||||
multiFile: false,
|
||||
errorCode: null,
|
||||
recordedAt: 0,
|
||||
});
|
||||
|
||||
continuation()({
|
||||
operation: "compress",
|
||||
inputFileIds: ["f-tool", "f-other"],
|
||||
// Only the other file produced an output; f-tool failed again, silently.
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["pdf"], "other.pdf"),
|
||||
fileId: "f-other-out",
|
||||
sourceFileId: "f-other",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve a row whose stash another kind's failure wrote", async () => {
|
||||
// The password failure's stash overwrote the compress one, so that row cannot use it.
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [toolRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
loadRetryPayload.mockResolvedValue({
|
||||
operation: "removePassword",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: {},
|
||||
fileIds: ["f-tool"],
|
||||
multiFile: false,
|
||||
errorCode: "E004",
|
||||
recordedAt: 0,
|
||||
});
|
||||
|
||||
continuation()({
|
||||
operation: "removePassword",
|
||||
inputFileIds: ["f-tool"],
|
||||
outputs: [
|
||||
{
|
||||
file: new File(["pdf"], "unlocked.pdf"),
|
||||
fileId: "f-out",
|
||||
sourceFileId: "f-tool",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve a tool failure from a different operation's success", async () => {
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [toolRow()],
|
||||
viewerReviewsTeam: false,
|
||||
});
|
||||
// The stash says compress failed; what succeeded is OCR on the same document.
|
||||
loadRetryPayload.mockResolvedValue({
|
||||
operation: "compress",
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
params: {},
|
||||
fileIds: ["f-tool"],
|
||||
recordedAt: 0,
|
||||
});
|
||||
|
||||
continuation()({
|
||||
operation: "ocr",
|
||||
inputFileIds: ["f-tool"],
|
||||
outputs: [],
|
||||
});
|
||||
|
||||
// The stash matched nothing, so this never even reached the network.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback } from "react";
|
||||
import { refreshNotificationsNow } from "@app/hooks/useNotifications";
|
||||
import {
|
||||
fetchNotifications,
|
||||
reportNotificationResolved,
|
||||
type AppNotification,
|
||||
} from "@app/services/notifications";
|
||||
import {
|
||||
loadRetryPayload,
|
||||
stashMatchesKind,
|
||||
} from "@app/services/notificationRetry";
|
||||
import { rechainPolicyOnDocument } from "@app/services/notificationPolicyRetry";
|
||||
import type {
|
||||
SucceededToolRun,
|
||||
ToolRunOutput,
|
||||
} from "@core/hooks/tools/shared/useResolutionContinuation";
|
||||
|
||||
export type { SucceededToolRun, ToolRunOutput };
|
||||
|
||||
// "Decrypt and retry" is just the remove-password tool, so reaching it directly asks the same.
|
||||
|
||||
/** Which tool's success counts as a kind's resolution. The server still decides WHO may fix it. */
|
||||
const RESOLUTION_TOOLS: Record<string, string> = {
|
||||
INPUT_PASSWORD_PROTECTED: "removePassword",
|
||||
};
|
||||
|
||||
export function useResolutionContinuation(): (run: SucceededToolRun) => void {
|
||||
return useCallback(
|
||||
(run: SucceededToolRun) => {
|
||||
void continueResolutions(run);
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/** Best-effort: a continuation that cannot happen leaves the row where it already was. */
|
||||
async function continueResolutions(
|
||||
run: SucceededToolRun,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!(await couldResolveAnything(run))) return;
|
||||
|
||||
const { notifications } = await fetchNotifications();
|
||||
// Only the reader's own rows: fixing a colleague's document by hand closes nothing.
|
||||
const candidates = notifications.filter(
|
||||
(row) =>
|
||||
row.ownership === "MINE" &&
|
||||
row.fileId !== null &&
|
||||
run.inputFileIds.includes(row.fileId),
|
||||
);
|
||||
|
||||
let resolvedAny = false;
|
||||
for (const row of candidates) {
|
||||
if (await continueRow(row, run)) resolvedAny = true;
|
||||
}
|
||||
if (resolvedAny) refreshNotificationsNow();
|
||||
} catch {
|
||||
// Their run succeeded, and the row this could not close still offers the same resolution.
|
||||
}
|
||||
}
|
||||
|
||||
/** Answered from local state alone, so an ordinary successful run costs no round-trip. */
|
||||
async function couldResolveAnything(run: SucceededToolRun): Promise<boolean> {
|
||||
if (Object.values(RESOLUTION_TOOLS).includes(run.operation)) return true;
|
||||
for (const fileId of run.inputFileIds) {
|
||||
const stash = await loadRetryPayload(fileId);
|
||||
if (stash?.operation === run.operation) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when the row was resolved server-side, so the caller re-reads the bell once at the end. */
|
||||
async function continueRow(
|
||||
row: AppNotification,
|
||||
run: SucceededToolRun,
|
||||
): Promise<boolean> {
|
||||
// Same precedence as the bell's retry target: the policy shape is the more specific claim.
|
||||
const attended = (row.sourceId ?? null) === null;
|
||||
if (attended && row.policyId && row.fileId) {
|
||||
if (RESOLUTION_TOOLS[row.kindId] !== run.operation) return false;
|
||||
// The fix is still this reader's to make; it has simply arrived by other means.
|
||||
if (!row.actions.some((a) => a.enabled && a.slot === "RESOLUTION")) {
|
||||
return false;
|
||||
}
|
||||
const output = outputFor(row.fileId, run);
|
||||
if (!output) return false;
|
||||
|
||||
// As the bell does: the ORIGINAL reference, output attributed to the workbench document.
|
||||
const outcome = await rechainPolicyOnDocument(
|
||||
{ policyId: row.policyId, fileId: row.fileId },
|
||||
output.file,
|
||||
output.fileId,
|
||||
);
|
||||
// Anything short of a tracked run leaves the row open: the processed document was the point.
|
||||
if (!(outcome.ok && outcome.tracked)) return false;
|
||||
return reportNotificationResolved(row.id);
|
||||
}
|
||||
|
||||
// A tool failure: resolved when the operation that failed succeeds on the same document.
|
||||
if (!row.fileId) return false;
|
||||
// Succeeded FOR THIS FILE: a batch can succeed for one input and fail for another.
|
||||
if (!outputFor(row.fileId, run)) return false;
|
||||
const stash = await loadRetryPayload(row.fileId);
|
||||
if (!stash || stash.operation !== run.operation) return false;
|
||||
// One stash per file but one incident per kind per file, so the stash may be another row's.
|
||||
if (!stashMatchesKind(row.kindId, stash)) return false;
|
||||
if (!row.actions.some((a) => a.enabled && a.id === "RETRY")) return false;
|
||||
return reportNotificationResolved(row.id);
|
||||
}
|
||||
|
||||
/** Paired by provenance, or by being the only one. Anything else stays open rather than guess. */
|
||||
function outputFor(
|
||||
fileId: string,
|
||||
run: SucceededToolRun,
|
||||
): ToolRunOutput | null {
|
||||
const paired = run.outputs.find((output) => output.sourceFileId === fileId);
|
||||
if (paired) return paired;
|
||||
if (run.inputFileIds.length === 1 && run.outputs.length === 1) {
|
||||
return run.outputs[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Nothing was stashed: the policy, the document and the bytes all come off the row's reference.
|
||||
|
||||
const getStirlingFile = vi.fn();
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getStirlingFile: (...args: unknown[]) => getStirlingFile(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const runStoredPolicy = vi.fn();
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: (...args: unknown[]) => runStoredPolicy(...args),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
|
||||
/** The local policy cache, which is how a backend policy id becomes a category without any hook. */
|
||||
const policies = vi.hoisted(() => ({
|
||||
value: { security: { backendId: "pol-1" } } as Record<
|
||||
string,
|
||||
{
|
||||
backendId?: string;
|
||||
// The rest of what the chain order is built from, so a test can make a policy eligible for it.
|
||||
configured?: boolean;
|
||||
status?: string;
|
||||
runOn?: string;
|
||||
sources?: string[];
|
||||
order?: number;
|
||||
}
|
||||
>,
|
||||
}));
|
||||
vi.mock("@app/services/policyStorage", () => ({
|
||||
loadPolicies: () => policies.value,
|
||||
}));
|
||||
|
||||
// The REAL run store: a mock would assert the call and prove nothing about the record.
|
||||
const { getRun, isDispatched, recordRunStart, resetPolicyRuns, updateRun } =
|
||||
await import("@app/components/policies/policyRunStore");
|
||||
const { rerunPolicy, rechainPolicyOnDocument } =
|
||||
await import("@app/services/notificationPolicyRetry");
|
||||
|
||||
const target = { policyId: "pol-1", fileId: "f-1" };
|
||||
|
||||
beforeEach(() => {
|
||||
getStirlingFile.mockReset().mockResolvedValue(null);
|
||||
runStoredPolicy.mockReset().mockResolvedValue("run-1");
|
||||
policies.value = { security: { backendId: "pol-1" } };
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
});
|
||||
|
||||
describe("rerunPolicy", () => {
|
||||
it("submits the stored document under the reference the failure named", async () => {
|
||||
const document = new File(["%PDF-1.7"], "invoice.pdf", {
|
||||
type: "application/pdf",
|
||||
});
|
||||
getStirlingFile.mockResolvedValue(document);
|
||||
|
||||
await expect(rerunPolicy(target)).resolves.toEqual({
|
||||
ok: true,
|
||||
tracked: true,
|
||||
});
|
||||
// The original reference, so the server folds a repeat onto the same incident.
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [document], "f-1");
|
||||
});
|
||||
|
||||
it("records the run so the editor polls it and delivers its output", async () => {
|
||||
// usePolicyAutoRun drives everything off the store, so being in the store IS the progress.
|
||||
const document = new File(["%PDF-1.7"], "invoice.pdf");
|
||||
getStirlingFile.mockResolvedValue(document);
|
||||
|
||||
await rerunPolicy(target);
|
||||
|
||||
expect(getRun("run-1")).toMatchObject({
|
||||
runId: "run-1",
|
||||
// The category the import step needs, and what the chain continues from.
|
||||
categoryId: "security",
|
||||
// The document that failed is still the document in the workspace, so the output belongs to it.
|
||||
fileId: "f-1",
|
||||
fileName: "invoice.pdf",
|
||||
status: "PENDING",
|
||||
target: "saas",
|
||||
});
|
||||
// Marked dispatched as any other run is, so the pair is not treated as never having run.
|
||||
expect(isDispatched("security", "f-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("still runs a policy the local cache cannot place, and says the run is untracked", async () => {
|
||||
// A run with no category cannot be imported or chained, so nothing delivers its output.
|
||||
policies.value = {};
|
||||
getStirlingFile.mockResolvedValue(new File(["%PDF-1.7"], "invoice.pdf"));
|
||||
|
||||
await expect(rerunPolicy(target)).resolves.toEqual({
|
||||
ok: true,
|
||||
tracked: false,
|
||||
});
|
||||
expect(runStoredPolicy).toHaveBeenCalled();
|
||||
expect(getRun("run-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records nothing when the run was refused, so no phantom sits in the feed", async () => {
|
||||
getStirlingFile.mockResolvedValue(new File(["%PDF-1.7"], "invoice.pdf"));
|
||||
runStoredPolicy.mockRejectedValue(new Error("refused"));
|
||||
|
||||
await rerunPolicy(target);
|
||||
|
||||
expect(getRun("run-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports the document is gone rather than submitting nothing", async () => {
|
||||
getStirlingFile.mockResolvedValue(null);
|
||||
|
||||
await expect(rerunPolicy(target)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missingFile",
|
||||
});
|
||||
expect(runStoredPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a browser that will not answer for the file as not having it", async () => {
|
||||
// Same outcome for the reader either way, and an exception here is not theirs to see.
|
||||
getStirlingFile.mockRejectedValue(new Error("storage unavailable"));
|
||||
|
||||
await expect(rerunPolicy(target)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missingFile",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rechainPolicyOnDocument", () => {
|
||||
const unlocked = new File(["%PDF-1.7"], "invoice.pdf");
|
||||
|
||||
it("submits bytes the caller already holds, still under the original reference", async () => {
|
||||
await expect(
|
||||
rechainPolicyOnDocument(target, unlocked, "f-unlocked", false),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
tracked: true,
|
||||
});
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1");
|
||||
// Nothing was read from storage: the unlocked document is not there and never will be.
|
||||
expect(getStirlingFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attributes the run to the adopted document, not the one the failure named", async () => {
|
||||
// Two references: the failure's to the server so a repeat folds on, the adopted one to the store.
|
||||
await rechainPolicyOnDocument(target, unlocked, "f-unlocked", false);
|
||||
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1");
|
||||
expect(getRun("run-1")).toMatchObject({ fileId: "f-unlocked" });
|
||||
expect(isDispatched("security", "f-unlocked")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs untracked rather than filing the output against the wrong document, and admits it", async () => {
|
||||
// No workspace id, so recording it would version the encrypted original instead.
|
||||
await expect(
|
||||
rechainPolicyOnDocument(target, unlocked, null, false),
|
||||
).resolves.toEqual({ ok: true, tracked: false });
|
||||
|
||||
expect(runStoredPolicy).toHaveBeenCalled();
|
||||
expect(getRun("run-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the server's own words when it refuses", async () => {
|
||||
runStoredPolicy.mockRejectedValue({
|
||||
response: { data: "That policy is no longer enabled." },
|
||||
});
|
||||
|
||||
await expect(
|
||||
rechainPolicyOnDocument(target, unlocked, "f-unlocked", false),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: "That policy is no longer enabled.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the message out of a structured error body too", async () => {
|
||||
runStoredPolicy.mockRejectedValue({
|
||||
response: { data: { message: "Job queue is full." } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
rechainPolicyOnDocument(target, unlocked, "f-unlocked", false),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: "Job queue is full.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says nothing rather than something unreadable, leaving the wording to the caller", async () => {
|
||||
runStoredPolicy.mockRejectedValue(new Error("Network Error"));
|
||||
|
||||
await expect(
|
||||
rechainPolicyOnDocument(target, unlocked, "f-unlocked", false),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Where the chain picks up again: the first upload policy not already applied.
|
||||
describe("rechainPolicyOnDocument rejoining the chain", () => {
|
||||
const unlocked = new File(["%PDF-1.7"], "invoice.pdf");
|
||||
|
||||
/** An upload policy as the local cache holds it, eligible for the chain. */
|
||||
function uploadPolicy(backendId: string, order: number) {
|
||||
return {
|
||||
backendId,
|
||||
configured: true,
|
||||
status: "active",
|
||||
runOn: "upload",
|
||||
sources: ["editor"],
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
/** A completed run of `categoryId` that turned `fileId` into `outputFileId`. */
|
||||
function completedRun(
|
||||
runId: string,
|
||||
categoryId: string,
|
||||
fileId: string,
|
||||
outputFileId: string,
|
||||
) {
|
||||
recordRunStart({
|
||||
runId,
|
||||
categoryId,
|
||||
fileId,
|
||||
fileName: "invoice.pdf",
|
||||
fileSize: 1,
|
||||
target: "saas",
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
});
|
||||
updateRun(runId, {
|
||||
status: "COMPLETED",
|
||||
imported: true,
|
||||
outputFileIds: [outputFileId],
|
||||
});
|
||||
}
|
||||
|
||||
it("resumes at the policy that failed when everything ahead of it has run", async () => {
|
||||
// watermark produced f-1, so rejoining must not stamp it twice and bill for the privilege.
|
||||
policies.value = {
|
||||
watermark: uploadPolicy("pol-w", 0),
|
||||
security: uploadPolicy("pol-1", 1),
|
||||
};
|
||||
completedRun("run-w", "watermark", "f-upload", "f-1");
|
||||
|
||||
await rechainPolicyOnDocument(target, unlocked, "f-unlocked", false);
|
||||
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1");
|
||||
});
|
||||
|
||||
it("resumes at an earlier policy that never ran, rather than skipping it", async () => {
|
||||
// The chain gained watermark after the failing run, so nothing has applied it to this document.
|
||||
policies.value = {
|
||||
watermark: uploadPolicy("pol-w", 0),
|
||||
security: uploadPolicy("pol-1", 1),
|
||||
};
|
||||
|
||||
await rechainPolicyOnDocument(target, unlocked, "f-unlocked", false);
|
||||
|
||||
// Filed under the resumed policy's category, so security still runs on watermark's output.
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-w", [unlocked], "f-1");
|
||||
expect(getRun("run-1")).toMatchObject({
|
||||
categoryId: "watermark",
|
||||
fileId: "f-unlocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves Classification out of the chain while the engine is off", async () => {
|
||||
// It classifies in the browser there, so it is not in the server chain nor a resume point.
|
||||
policies.value = {
|
||||
classification: uploadPolicy("pol-c", 0),
|
||||
security: uploadPolicy("pol-1", 1),
|
||||
};
|
||||
|
||||
await rechainPolicyOnDocument(target, unlocked, "f-unlocked", false);
|
||||
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1");
|
||||
});
|
||||
|
||||
it("re-runs only what failed when the policy is not in the upload chain at all", async () => {
|
||||
// An export-mode policy has no upload chain to rejoin, so the retry is a plain re-run.
|
||||
policies.value = {
|
||||
watermark: uploadPolicy("pol-w", 0),
|
||||
security: { ...uploadPolicy("pol-1", 1), runOn: "export" },
|
||||
};
|
||||
|
||||
await rechainPolicyOnDocument(target, unlocked, "f-unlocked", false);
|
||||
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [unlocked], "f-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
appliedCategoriesFor,
|
||||
recordRunStart,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import { orderedRewritingCategories } from "@app/data/classificationPolicy";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { loadPolicies } from "@app/services/policyStorage";
|
||||
import {
|
||||
resolvePolicyRunTarget,
|
||||
runStoredPolicy,
|
||||
} from "@app/services/policyApi";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
// No stash needed: the row names the policy and the reference the bytes are stored under.
|
||||
|
||||
/** The document, and the policy to put it back through. Both read straight off the notification. */
|
||||
export interface PolicyRetryTarget {
|
||||
policyId: string;
|
||||
/** Sent back unchanged, so the server folds a repeat failure onto the same incident. */
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/** `ok` without `tracked` means nothing polls the run, so no output reaches this workspace. */
|
||||
export type PolicyRerunOutcome =
|
||||
/** In the store, so `usePolicyAutoRun` polls it to terminal and imports what it produced. */
|
||||
| { ok: true; tracked: true }
|
||||
/** Running on the server, with nothing here to collect it. See {@link submit}. */
|
||||
| { ok: true; tracked: false }
|
||||
| { ok: false; reason: "missingFile" }
|
||||
/** The server refused the run. `message` is its own, or null when it gave nothing usable. */
|
||||
| { ok: false; reason: "rejected"; message: string | null };
|
||||
|
||||
/** Re-run on the document still in this browser's storage, under the reference the failure named. */
|
||||
export async function rerunPolicy(
|
||||
target: PolicyRetryTarget,
|
||||
): Promise<PolicyRerunOutcome> {
|
||||
let document: File | null = null;
|
||||
try {
|
||||
document = await fileStorage.getStirlingFile(target.fileId as FileId);
|
||||
} catch {
|
||||
// Treated as absent: a browser that will not answer for the file cannot supply its bytes.
|
||||
document = null;
|
||||
}
|
||||
if (!document) return { ok: false, reason: "missingFile" };
|
||||
|
||||
// The document that failed is still the one in the workspace, so the output belongs to it.
|
||||
return submit(target, document, target.fileId);
|
||||
}
|
||||
|
||||
/** Rejoins the chain at {@link resumePointFor}; `workspaceFileId` is the ADOPTED document. */
|
||||
export async function rechainPolicyOnDocument(
|
||||
target: PolicyRetryTarget,
|
||||
document: File,
|
||||
workspaceFileId: string | null,
|
||||
): Promise<PolicyRerunOutcome> {
|
||||
return submit(
|
||||
target,
|
||||
document,
|
||||
workspaceFileId,
|
||||
resumePointFor(target),
|
||||
);
|
||||
}
|
||||
|
||||
/** Which policy a retry should actually run, and the category to file its run under. */
|
||||
interface ChainEntry {
|
||||
policyId: string;
|
||||
categoryId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain's first policy not already applied, so one that succeeded does not run twice. Null for
|
||||
* an annotating policy, which is not in the chain and is simply re-run on its own.
|
||||
*/
|
||||
function resumePointFor(target: PolicyRetryTarget): ChainEntry | null {
|
||||
const policies = loadPolicies();
|
||||
const chain = orderedRewritingCategories(policies);
|
||||
const failed = categoryForPolicy(target.policyId);
|
||||
if (!failed || !chain.includes(failed)) return null;
|
||||
|
||||
const applied = appliedCategoriesFor(target.fileId);
|
||||
const categoryId = chain.find((category) => !applied.has(category));
|
||||
if (!categoryId) return null;
|
||||
const policyId = Object.entries(policies).find(
|
||||
([id]) => id === categoryId,
|
||||
)?.[1]?.backendId;
|
||||
return policyId ? { policyId, categoryId } : null;
|
||||
}
|
||||
|
||||
/** Recorded because `usePolicyAutoRun` polls the store; an unrecorded run delivers nothing. */
|
||||
async function submit(
|
||||
target: PolicyRetryTarget,
|
||||
document: File,
|
||||
workspaceFileId: string | null,
|
||||
resume: ChainEntry | null = null,
|
||||
): Promise<PolicyRerunOutcome> {
|
||||
// Resolved before the run, so a lookup that throws cannot leave a live run unrecorded.
|
||||
const categoryId = resume?.categoryId ?? categoryForPolicy(target.policyId);
|
||||
// At the resume point where there is one, so the rest of the chain carries on from there.
|
||||
const policyId = resume?.policyId ?? target.policyId;
|
||||
const runTarget = resolvePolicyRunTarget();
|
||||
|
||||
let runId: string;
|
||||
try {
|
||||
runId = await runStoredPolicy(policyId, [document], target.fileId);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: "rejected", message: rejectionMessage(error) };
|
||||
}
|
||||
|
||||
// The run already went, so it is left to run: refusing now only wastes a second submission.
|
||||
if (!categoryId || !workspaceFileId) return { ok: true, tracked: false };
|
||||
|
||||
// Marks (category, file) dispatched as it records: the pair has run once, and this is it again.
|
||||
recordRunStart({
|
||||
runId,
|
||||
categoryId,
|
||||
fileId: workspaceFileId,
|
||||
fileName: document.name,
|
||||
fileSize: document.size,
|
||||
target: runTarget,
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
return { ok: true, tracked: true };
|
||||
}
|
||||
|
||||
/** Non-hook read: `usePolicies` needs contexts the bell's shell may not have. */
|
||||
function categoryForPolicy(policyId: string): string | undefined {
|
||||
try {
|
||||
return Object.entries(loadPolicies()).find(
|
||||
([, state]) => state.backendId === policyId,
|
||||
)?.[0];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the server said, when it said anything readable. Nothing is interpolated here. */
|
||||
function rejectionMessage(error: unknown): string | null {
|
||||
const data = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
if (typeof data === "string" && data.trim() !== "") return data;
|
||||
|
||||
const message = (data as { message?: unknown } | undefined)?.message;
|
||||
return typeof message === "string" && message.trim() !== "" ? message : null;
|
||||
}
|
||||
Reference in New Issue
Block a user