mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
feat(failure): resolve a failed run by retrying or decrypting and retrying
All resolution of a recorded failure lives here, on top of the bell that surfaces it: - The retry stash: what a failed tool run would need to run again (endpoint, parameters, file ids), kept client-side with password-shaped fields stripped at any depth and a depth-bounded walk that fails closed. - Editor retry and decrypt-and-retry: re-open the failed tool, or unlock a password-protected document and re-run it in place. - Server policy retry: an attended policy failure is re-run with the password the user supplies, exactly once per click. The action slot model arrives with them. Slots rank an action as the row's resolution, its runner-up, or overflow, which is only a question worth asking once a kind has a resolution to rank: the bell alone offers viewing and dismissal, in declaration order. promoteActions turns those slots into the row's primary and secondary buttons and reports what it withheld. A build without this PR still shows every failure; the server declares RETRY and DECRYPT_AND_RETRY there and a client with no handler for them skips them.
This commit is contained in:
+6
@@ -24,6 +24,12 @@ public enum FailureActionId {
|
||||
/** "No remediation will happen; close it." See {@link DismissAction}. */
|
||||
DISMISS(Execution.SERVER, "Dismiss"),
|
||||
|
||||
/** Run the failed operation again on the document the client still holds. */
|
||||
RETRY(Execution.CLIENT, "Retry"),
|
||||
|
||||
/** Ask the owner for the password, unlock the document in their client, then retry. */
|
||||
DECRYPT_AND_RETRY(Execution.CLIENT, "Decrypt and retry"),
|
||||
|
||||
/** Open the document this incident is about. Only its owner's client can resolve the id. */
|
||||
VIEW_FILE(Execution.CLIENT, "View file"),
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* Where a kind would like one of its actions to sit: the thing that fixes it, a supporting action,
|
||||
* or one folded away in a menu.
|
||||
*
|
||||
* <p>Intent, not layout. The client does the final promotion, because only it knows whether the
|
||||
* document is still in its own file store, and a resolution it cannot run is worth less than a
|
||||
* secondary action it can. Declaration order in {@link FailureKind} breaks a tie within a slot.
|
||||
*/
|
||||
public enum FailureActionSlot {
|
||||
|
||||
/** The action that resolves the failure. At most one per kind is worth declaring here. */
|
||||
RESOLUTION,
|
||||
|
||||
/** Offered alongside the resolution, for a caller the resolution is not aimed at. */
|
||||
SECONDARY,
|
||||
|
||||
/** Available but folded away: correct, rarely the next thing anyone wants to press. */
|
||||
OVERFLOW
|
||||
}
|
||||
+67
-34
@@ -1,8 +1,12 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static stirling.software.proprietary.failure.FailureActionId.DECRYPT_AND_RETRY;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.RETRY;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
|
||||
@@ -29,9 +33,9 @@ import lombok.Getter;
|
||||
* ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every failed run a
|
||||
* record, and kinds get promoted out of it as production shows what occurs.
|
||||
*
|
||||
* <p>Each offer also says who it is for, because the same incident is read by the person who hit it
|
||||
* and by whoever reviews after them: only the owner holds the document, only a reviewer wants the
|
||||
* run.
|
||||
* <p>Each offer also says who it is for and where the kind wants it, because the same incident is
|
||||
* read by the person who hit it and by whoever reviews after them: only the owner can supply a
|
||||
* password, only a reviewer wants the run.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureKind {
|
||||
@@ -42,11 +46,13 @@ public enum FailureKind {
|
||||
FailureScope.FILE,
|
||||
errorCodes("E004"),
|
||||
fallback("This document is password-protected, so the pipeline could not read it."),
|
||||
// Only the owner holds the document, so the file is theirs to open; a reviewer
|
||||
// gets the run instead, and anyone who sees the row may close it.
|
||||
offer(VIEW_FILE, OWNER),
|
||||
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
|
||||
offer(DISMISS, ANYONE_WHO_SEES)),
|
||||
// The password is the fix and only the owner has it, so everyone else is
|
||||
// offered the run and a way to close the row.
|
||||
resolution(DECRYPT_AND_RETRY, OWNER),
|
||||
global(RETRY, OWNER, OVERFLOW),
|
||||
global(VIEW_FILE, OWNER, OVERFLOW),
|
||||
global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, SECONDARY),
|
||||
global(DISMISS, ANYONE_WHO_SEES, OVERFLOW)),
|
||||
|
||||
UNKNOWN(
|
||||
FailureStage.INTERNAL,
|
||||
@@ -55,14 +61,12 @@ public enum FailureKind {
|
||||
FailureScope.RUN,
|
||||
noErrorCodes(),
|
||||
fallback("This run failed for a reason Stirling does not yet recognise."),
|
||||
// Nothing here is known to be fixable, so the offers are the places to look:
|
||||
// the owner their document, a reviewer the run, and anyone may close the row.
|
||||
// Declared in the same order as every other kind, because declaration order is
|
||||
// display order: the document leads wherever it is offered, so a reader is not
|
||||
// asked to re-learn which button leads from one failure to the next.
|
||||
offer(VIEW_FILE, OWNER),
|
||||
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
|
||||
offer(DISMISS, ANYONE_WHO_SEES));
|
||||
// Nothing here is known to be fixable, so there is no resolution to declare. A plain
|
||||
// retry is still worth offering: an unrecognised failure is often a one-off.
|
||||
global(RETRY, OWNER, SECONDARY),
|
||||
global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, SECONDARY),
|
||||
global(VIEW_FILE, OWNER, OVERFLOW),
|
||||
global(DISMISS, ANYONE_WHO_SEES, OVERFLOW));
|
||||
|
||||
private static final String KEY_PREFIX = "portal.failures.kind.";
|
||||
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
|
||||
@@ -110,30 +114,52 @@ public enum FailureKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* One action this kind offers: who it is for, and the key to label it by. One ordered list
|
||||
* rather than ids plus parallel maps of audiences and label overrides, which could disagree
|
||||
* with each other.
|
||||
* One action this kind offers: who it is for, where it wants to sit, and the key to label it
|
||||
* by. One ordered list rather than ids plus parallel maps of audiences, slots and label
|
||||
* overrides, which could disagree with each other.
|
||||
*
|
||||
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
|
||||
* label
|
||||
*/
|
||||
private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {}
|
||||
private record Offer(
|
||||
FailureActionId id,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot,
|
||||
String labelKeySuffix) {}
|
||||
|
||||
/**
|
||||
* An action this kind offers, for whoever can actually take it, labelled by the shared wording.
|
||||
* Declaration order is display order.
|
||||
* The action that fixes this kind, for whoever can actually apply it. In the resolution slot by
|
||||
* definition: a kind needing two of these would be two kinds.
|
||||
*/
|
||||
private static Offer offer(FailureActionId id, FailureAudience audience) {
|
||||
return new Offer(id, audience, null);
|
||||
private static Offer resolution(FailureActionId id, FailureAudience audience) {
|
||||
return new Offer(id, audience, FailureActionSlot.RESOLUTION, null);
|
||||
}
|
||||
|
||||
/** As {@link #resolution(FailureActionId, FailureAudience)}, with this kind's own wording. */
|
||||
private static Offer resolution(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
return new Offer(id, audience, FailureActionSlot.RESOLUTION, labelKeySuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording
|
||||
* where the shared one reads badly.
|
||||
* An action that is not this kind's fix: the same offer any kind can make, placed where this
|
||||
* kind wants it and labelled by the shared wording.
|
||||
*/
|
||||
private static Offer offer(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
return new Offer(id, audience, labelKeySuffix);
|
||||
private static Offer global(
|
||||
FailureActionId id, FailureAudience audience, FailureActionSlot slot) {
|
||||
return new Offer(id, audience, slot, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #global(FailureActionId, FailureAudience, FailureActionSlot)}, but labelled by this
|
||||
* kind's own wording where the shared one reads badly.
|
||||
*/
|
||||
private static Offer global(
|
||||
FailureActionId id,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot,
|
||||
String labelKeySuffix) {
|
||||
return new Offer(id, audience, slot, labelKeySuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,20 +199,27 @@ public enum FailureKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* What this kind offers, in declaration order, each with its label resolved. What a review
|
||||
* surface reads, so it never has to ask two separate questions about one offer.
|
||||
* What this kind offers, in declaration order, each with its label and placement resolved. What
|
||||
* a review surface reads, so it never has to ask three separate questions about one offer.
|
||||
*/
|
||||
public List<OfferedAction> getOfferedActions() {
|
||||
return offers.stream()
|
||||
.map(
|
||||
offer ->
|
||||
new OfferedAction(
|
||||
offer.id(), labelKeyFor(offer.id()), offer.audience()))
|
||||
offer.id(),
|
||||
labelKeyFor(offer.id()),
|
||||
offer.audience(),
|
||||
offer.slot()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** One action as a kind declares it: what to call it and who it is for. */
|
||||
public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {}
|
||||
/** One action as a kind declares it: what to call it, who it is for, where it wants to sit. */
|
||||
public record OfferedAction(
|
||||
FailureActionId id,
|
||||
String labelKey,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot) {}
|
||||
|
||||
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
|
||||
public boolean declares(FailureActionId action) {
|
||||
|
||||
+31
-3
@@ -173,6 +173,26 @@ public class FileRunEventService {
|
||||
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an incident resolved, because a client retried the operation and it worked. Nobody is
|
||||
* offered a "resolve" button, which is why {@code RESOLVED} is a status and not a {@link
|
||||
* FailureActionId}. Idempotent: a client reporting the same success twice reads its row back.
|
||||
*
|
||||
* @throws FailureActionException if the event is not the caller's, or is already closed some
|
||||
* other way
|
||||
*/
|
||||
public FileRunEvent resolve(String eventId) {
|
||||
FileRunEvent event = requireVisible(eventId);
|
||||
// No terminal pre-check: the store's guarded UPDATE decides, and tells a dismissed row
|
||||
// apart from a deleted one after the fact rather than racing a read against the write.
|
||||
return store.applyStatusOnce(
|
||||
event.id(),
|
||||
event.teamId(),
|
||||
FileRunEventStatus.RESOLVED,
|
||||
currentActor(),
|
||||
FileRunEventStatus.open());
|
||||
}
|
||||
|
||||
/**
|
||||
* The event, if this caller may act on it at all. Reported as "no such event" rather than a
|
||||
* refusal, so a member cannot learn that a colleague's incident exists by trying to close it.
|
||||
@@ -232,7 +252,8 @@ public class FileRunEventService {
|
||||
boolean unattended,
|
||||
boolean documentless) {
|
||||
String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless);
|
||||
return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason);
|
||||
return new AvailableAction(
|
||||
offer.id(), offer.labelKey(), offer.slot(), reason == null, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,7 +364,14 @@ public class FileRunEventService {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
/** One action as offered to one caller about one event, with its availability resolved. */
|
||||
/**
|
||||
* One action as offered to one caller about one event, with its availability resolved. {@code
|
||||
* slot} is the kind's placement intent, carried through for the client to make the final call.
|
||||
*/
|
||||
public record AvailableAction(
|
||||
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
|
||||
FailureActionId id,
|
||||
String labelKey,
|
||||
FailureActionSlot slot,
|
||||
boolean enabled,
|
||||
String disabledReasonKey) {}
|
||||
}
|
||||
|
||||
+4
-1
@@ -63,13 +63,15 @@ public record FileRunEventView(
|
||||
/**
|
||||
* One button, as offered to this caller about this row. {@code defaultLabel} and {@code
|
||||
* execution} are here for the reason {@code defaultTitle} is on the row: a client can then
|
||||
* render, and route, an action it was never built with. Declaration order is display order.
|
||||
* render, and route, an action it was never built with. {@code slot} is placement intent; see
|
||||
* {@link FailureActionSlot}.
|
||||
*/
|
||||
public record ActionView(
|
||||
String id,
|
||||
String labelKey,
|
||||
String defaultLabel,
|
||||
FailureActionId.Execution execution,
|
||||
FailureActionSlot slot,
|
||||
boolean enabled,
|
||||
String disabledReasonKey) {
|
||||
|
||||
@@ -80,6 +82,7 @@ public record FileRunEventView(
|
||||
action.labelKey(),
|
||||
action.id().getDefaultLabel(),
|
||||
action.id().getExecution(),
|
||||
action.slot(),
|
||||
action.enabled(),
|
||||
action.disabledReasonKey());
|
||||
}
|
||||
|
||||
+35
-3
@@ -2,10 +2,14 @@ package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -13,14 +17,16 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.failure.FailureActionException;
|
||||
|
||||
/**
|
||||
* The caller's notifications. Open to any authenticated user, unlike the failure endpoints it draws
|
||||
* on: each source scopes its own rows and resolves its own actions, so a member is told about their
|
||||
* own failures and a leader about their team's.
|
||||
*
|
||||
* <p>Read-only: every action a notification offers is one the client runs on its own device, so
|
||||
* there is nothing to post back here yet. Ids are still prefixed with their source, so the bell is
|
||||
* never given the producing row's id; see {@link NotificationSource}.
|
||||
* <p>Every action a notification offers is one the client runs on its own device, so the only write
|
||||
* here is the client reporting that its own retry worked. Ids are prefixed with their source, so
|
||||
* the bell is never given the producing row's id; see {@link NotificationSource}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
@@ -47,6 +53,32 @@ public class NotificationController {
|
||||
return new NotificationsResponse(notifications.list(capped));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the client's own retry of this notification worked. A mirror of the failure
|
||||
* surface's resolve semantics rather than a client that strips the prefix, because the bell
|
||||
* must never hand a raw failure id to a failure endpoint.
|
||||
*
|
||||
* <p>Idempotent. A row a reviewer has since dismissed is a conflict, not a silent overwrite of
|
||||
* their decision.
|
||||
*/
|
||||
@PostMapping("/{notificationId}/resolved")
|
||||
@Operation(
|
||||
summary = "Record that a client-side retry fixed what a notification was about",
|
||||
description =
|
||||
"Takes the prefixed notification id, not the producing row's id. Not an action:"
|
||||
+ " nobody is offered a resolve button, and a recurrence brings the"
|
||||
+ " notification back.")
|
||||
public NotificationView resolved(@PathVariable String notificationId) {
|
||||
try {
|
||||
return notifications.resolve(notificationId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
|
||||
} catch (FailureActionException e) {
|
||||
throw new ResponseStatusException(
|
||||
FailureActionException.statusOf(e.getReason()), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapped rather than a bare array so paging or a total can be added without breaking clients.
|
||||
*/
|
||||
|
||||
+30
-3
@@ -11,9 +11,10 @@ import stirling.software.proprietary.failure.FileRunEventService;
|
||||
import stirling.software.proprietary.failure.FileRunEventView;
|
||||
|
||||
/**
|
||||
* Assembles the caller's notifications from whatever produces them. Derived on read rather than
|
||||
* stored: there is one source today, and a table would need a write path, a retention story and a
|
||||
* per-user read model before it earned itself.
|
||||
* Assembles the caller's notifications from whatever produces them, and routes a client's report of
|
||||
* its own fix back to whichever source produced the row. Derived on read rather than stored: there
|
||||
* is one source today, and a table would need a write path, a retention story and a per-user read
|
||||
* model before it earned itself.
|
||||
*
|
||||
* <p>Who sees what is decided by each source rather than here. {@link FileRunEventService} already
|
||||
* scopes its reads and resolves each row's actions against that reader, so this cannot widen either
|
||||
@@ -33,6 +34,32 @@ public class NotificationService {
|
||||
return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the client's own retry of this notification worked, and return it as it now
|
||||
* stands. Takes the prefixed id even though nobody pressed a button: it is still a call made
|
||||
* from the bell, and the bell holds no raw failure id, so it cannot reach a failure endpoint
|
||||
* even by accident.
|
||||
*
|
||||
* @throws IllegalArgumentException if the id names no source this build has
|
||||
* @throws stirling.software.proprietary.failure.FailureActionException if the source refuses,
|
||||
* e.g. a row a reviewer has since dismissed
|
||||
*/
|
||||
public NotificationView resolve(String notificationId) {
|
||||
NotificationSource.QualifiedId qualified = qualify(notificationId);
|
||||
return switch (qualified.source()) {
|
||||
case FAILURE -> fromFailure(fileRunEvents.resolve(qualified.rowId()));
|
||||
};
|
||||
}
|
||||
|
||||
/** The source and row id behind a notification id, refusing anything that is not one. */
|
||||
private static NotificationSource.QualifiedId qualify(String notificationId) {
|
||||
return NotificationSource.parse(notificationId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"Not a notification id: " + notificationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* One failure as a notification, with its row id prefixed on the way out and never sent bare.
|
||||
*/
|
||||
|
||||
+28
-2
@@ -1,13 +1,16 @@
|
||||
package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which subsystem produced a notification. One member today; the point of the field is that a
|
||||
* client already branches on it, so a second source needs no client change to be ignorable.
|
||||
*
|
||||
* <p>Every notification id is prefixed with its source, so a client never holds the producing row's
|
||||
* own id and cannot hand it to that source's endpoints by accident.
|
||||
* <p>Also the routing table for a notification id: every id is prefixed with its source, so an id
|
||||
* handed back to a notification endpoint says which subsystem to ask, and a client never holds the
|
||||
* producing row's own id.
|
||||
*/
|
||||
public enum NotificationSource {
|
||||
|
||||
@@ -25,4 +28,27 @@ public enum NotificationSource {
|
||||
public String qualify(String sourceRowId) {
|
||||
return prefix() + sourceRowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source a notification id belongs to, and the row id within it. Empty rather than throwing
|
||||
* for an unprefixed id or an unknown source, since both arrive from clients.
|
||||
*/
|
||||
public static Optional<QualifiedId> parse(String notificationId) {
|
||||
if (notificationId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int separator = notificationId.indexOf(SEPARATOR);
|
||||
if (separator <= 0 || separator == notificationId.length() - 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String prefix = notificationId.substring(0, separator);
|
||||
String rowId = notificationId.substring(separator + 1);
|
||||
return Arrays.stream(values())
|
||||
.filter(source -> source.name().equalsIgnoreCase(prefix))
|
||||
.findFirst()
|
||||
.map(source -> new QualifiedId(source, rowId));
|
||||
}
|
||||
|
||||
/** A notification id split into the source that owns it and that source's own row id. */
|
||||
public record QualifiedId(NotificationSource source, String rowId) {}
|
||||
}
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ class CheckConstrainedEnumsTest {
|
||||
assertThat(persisted)
|
||||
.doesNotContain(
|
||||
FailureAudience.class,
|
||||
FailureActionSlot.class,
|
||||
FailureActionId.class,
|
||||
FailureActionId.Execution.class,
|
||||
Ownership.class);
|
||||
|
||||
+44
-39
@@ -1,6 +1,9 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.RESOLUTION;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
|
||||
@@ -37,9 +40,12 @@ class FailureKindTest {
|
||||
* declaration that pairs the right action with the wrong audience cannot pass.
|
||||
*/
|
||||
private static FailureKind.OfferedAction offered(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
FailureActionId id,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot,
|
||||
String labelKeySuffix) {
|
||||
return new FailureKind.OfferedAction(
|
||||
id, "portal.failures.action." + labelKeySuffix, audience);
|
||||
id, "portal.failures.action." + labelKeySuffix, audience, slot);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -73,28 +79,6 @@ class FailureKindTest {
|
||||
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void declaresItsActionsInTheSameOrderAsEveryOtherKind(FailureKind kind) {
|
||||
// Declaration order is display order, and the first offer a reader can use is the one
|
||||
// rendered as the row's primary. Two kinds listing the same actions in different orders
|
||||
// therefore flip the solid button between rows, which reads as a bug rather than as
|
||||
// emphasis. Asserted as a shared ranking so a kind added later cannot reintroduce it.
|
||||
List<FailureActionId> ranking =
|
||||
List.of(
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
FailureActionId.DISMISS);
|
||||
|
||||
List<FailureActionId> declared = kind.getActions();
|
||||
assertThat(ranking)
|
||||
.as("%s declares an action the shared ranking does not rank", kind.getId())
|
||||
.containsAll(declared);
|
||||
assertThat(declared)
|
||||
.as("%s declares its actions out of the shared order", kind.getId())
|
||||
.isEqualTo(ranking.stream().filter(declared::contains).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idsAreUnique() {
|
||||
Set<String> ids = new HashSet<>();
|
||||
@@ -125,13 +109,14 @@ class FailureKindTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void everyOfferSaysWhoItIsFor(FailureKind kind) {
|
||||
// Read per row to decide what a caller is shown, so a missing one would be a button
|
||||
// offered to whoever the null case happened to let through.
|
||||
void everyOfferSaysWhoItIsForAndWhereItGoes(FailureKind kind) {
|
||||
// Both are read per row to decide what a caller is shown, so a missing one would be a
|
||||
// button placed by whatever the null case happened to do.
|
||||
for (FailureKind.OfferedAction offer : kind.getOfferedActions()) {
|
||||
assertThat(offer.audience())
|
||||
.as("%s offers %s", kind.getId(), offer.id())
|
||||
.isNotNull();
|
||||
assertThat(offer.slot()).as("%s offers %s", kind.getId(), offer.id()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +128,17 @@ class FailureKindTest {
|
||||
assertThat(kind.getActions()).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void declaresAtMostOneResolution(FailureKind kind) {
|
||||
// Two things that both claim to fix it is a sign of two kinds wearing one id.
|
||||
assertThat(
|
||||
kind.getOfferedActions().stream()
|
||||
.filter(offer -> offer.slot() == FailureActionSlot.RESOLUTION)
|
||||
.toList())
|
||||
.hasSizeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTwoKindsClaimTheSameErrorCode() {
|
||||
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
|
||||
@@ -237,17 +233,19 @@ class FailureKindTest {
|
||||
class Unknown {
|
||||
|
||||
@Test
|
||||
void offersItsOwnerTheirDocumentAndTheRunToWhoeverReviews() {
|
||||
// Nothing here is known to be fixable, so the offers are the places to look: the
|
||||
// owner their document, a reviewer the run, and anyone may close the row.
|
||||
void offersARetryToItsOwnerAndTheRunToWhoeverReviews() {
|
||||
// Nothing here is known to be fixable, so there is no resolution. A retry is still
|
||||
// worth offering the person who hit it: an unrecognised failure is often a one-off.
|
||||
assertThat(FailureKind.UNKNOWN.getOfferedActions())
|
||||
.containsExactly(
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
|
||||
offered(FailureActionId.RETRY, OWNER, SECONDARY, "retry"),
|
||||
offered(
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
TEAM_REVIEWER,
|
||||
SECONDARY,
|
||||
"viewInProcessor"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, OVERFLOW, "viewFile"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -300,17 +298,24 @@ class FailureKindTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersTheDocumentToItsOwnerAndTheRunToItsReviewer() {
|
||||
// The whole point of the audiences: only the owner holds the document, so a reviewer
|
||||
// is offered the run and a way to close the row instead.
|
||||
void aKindWithSomethingToFixOffersTheFixToItsOwnerAndTheRunToItsReviewer() {
|
||||
// The whole point of the audiences: the password is the fix and only the owner has it,
|
||||
// so a reviewer is offered the run and a way to close the row instead.
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions())
|
||||
.containsExactly(
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
|
||||
offered(
|
||||
FailureActionId.DECRYPT_AND_RETRY,
|
||||
OWNER,
|
||||
RESOLUTION,
|
||||
"decryptAndRetry"),
|
||||
offered(FailureActionId.RETRY, OWNER, OVERFLOW, "retry"),
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, OVERFLOW, "viewFile"),
|
||||
offered(
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
TEAM_REVIEWER,
|
||||
SECONDARY,
|
||||
"viewInProcessor"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -343,8 +348,8 @@ class FailureKindTest {
|
||||
.isEqualTo("portal.failures.action.dismiss");
|
||||
assertThat(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(
|
||||
FailureActionId.VIEW_IN_PROCESSOR))
|
||||
.isEqualTo("portal.failures.action.viewInProcessor");
|
||||
FailureActionId.DECRYPT_AND_RETRY))
|
||||
.isEqualTo("portal.failures.action.decryptAndRetry");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -153,6 +153,7 @@ class FileRunEventControllerTest {
|
||||
action -> {
|
||||
assertThat(action.defaultLabel()).isNotBlank();
|
||||
assertThat(action.execution()).isNotNull();
|
||||
assertThat(action.slot()).isNotNull();
|
||||
})
|
||||
.filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id()))
|
||||
.singleElement()
|
||||
@@ -160,6 +161,7 @@ class FileRunEventControllerTest {
|
||||
action -> {
|
||||
assertThat(action.execution())
|
||||
.isEqualTo(FailureActionId.Execution.CLIENT);
|
||||
assertThat(action.slot()).isEqualTo(FailureActionSlot.SECONDARY);
|
||||
assertThat(action.defaultLabel()).isEqualTo("View in processor");
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -130,6 +130,7 @@ class FileRunEventHttpIntegrationTest {
|
||||
assertThat(actions.get(0).get("defaultLabel").asString())
|
||||
.isEqualTo("View in processor");
|
||||
assertThat(actions.get(0).get("execution").asString()).isEqualTo("CLIENT");
|
||||
assertThat(actions.get(0).get("slot").asString()).isEqualTo("SECONDARY");
|
||||
assertThat(actions.get(0).get("enabled").asBoolean()).isTrue();
|
||||
assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue();
|
||||
assertThat(actions.get(1).get("id").asString()).isEqualTo("DISMISS");
|
||||
|
||||
+89
-3
@@ -161,6 +161,75 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("resolve")
|
||||
class Resolve {
|
||||
|
||||
@Test
|
||||
void marksTheRowResolvedWhenAClientReportsItsRetryWorked() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
FileRunEvent resolved = service.resolve(event.id());
|
||||
|
||||
assertThat(resolved.status()).isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
assertThat(resolved.statusActor()).isEqualTo(ACTOR);
|
||||
assertThat(service.list(null, null, 10)).as("resolved work is not open work").isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isNotAnActionAnyoneCanPress() {
|
||||
// System-set on a successful client-side retry, so there is no id to dispatch and no
|
||||
// button to render. Stated as a test because the absence is the property.
|
||||
assertThat(Arrays.stream(FailureActionId.values()).map(Enum::name))
|
||||
.doesNotContain("RESOLVE", "RESOLVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportingTheSameSuccessTwiceIsNotARefusal() {
|
||||
// A client that retries, succeeds and reports twice is telling the truth twice.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
Instant first = service.resolve(event.id()).statusAt();
|
||||
|
||||
assertThat(service.resolve(event.id()).statusAt()).isEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDismissedRowCannotBeResolvedBehindTheReviewersBack() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> service.resolve(event.id()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherTeamsRowIsNotFound() {
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.resolve(theirs.id()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRecurrenceReopensIt() {
|
||||
// RESOLVED claims one attempt worked, not that the problem is gone for good.
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
|
||||
FileRunEvent event = service.list(null, null, 10).getFirst();
|
||||
service.resolve(event.id());
|
||||
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
|
||||
|
||||
assertThat(service.list(null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::status)
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("triage never touches the document")
|
||||
class NeverTouchesTheDocument {
|
||||
@@ -359,14 +428,18 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnerIsOfferedTheirDocumentAndNotTheReviewersView() {
|
||||
// A member reading their own password failure: the document is theirs to open, and the
|
||||
void theOwnerIsOfferedTheFixAndNotTheReviewersView() {
|
||||
// A member reading their own password failure: the unlock is theirs to do, and the
|
||||
// processor view is for whoever reviews the team rather than owns the file.
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(offeredFor(mine))
|
||||
.containsExactly(FailureActionId.VIEW_FILE, FailureActionId.DISMISS);
|
||||
.containsExactly(
|
||||
FailureActionId.DECRYPT_AND_RETRY,
|
||||
FailureActionId.RETRY,
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.DISMISS);
|
||||
assertThat(service.availableActions(mine))
|
||||
.allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
}
|
||||
@@ -394,6 +467,8 @@ class FileRunEventServiceTest {
|
||||
|
||||
assertThat(offeredFor(unattended))
|
||||
.containsExactly(
|
||||
FailureActionId.DECRYPT_AND_RETRY,
|
||||
FailureActionId.RETRY,
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
FailureActionId.DISMISS);
|
||||
@@ -510,6 +585,17 @@ class FileRunEventServiceTest {
|
||||
.equals(action.disabledReasonKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheKindsPlacementIntentForEachOffer() {
|
||||
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(mine))
|
||||
.filteredOn(action -> action.id() == FailureActionId.DECRYPT_AND_RETRY)
|
||||
.singleElement()
|
||||
.extracting(FileRunEventService.AvailableAction::slot)
|
||||
.isEqualTo(FailureActionSlot.RESOLUTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheLabelKeyForEachOffer() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ class NotificationProjectionTest {
|
||||
assertThat(action.labelKey()).startsWith("portal.failures.action.");
|
||||
assertThat(action.defaultLabel()).isNotBlank();
|
||||
assertThat(action.execution()).isNotNull();
|
||||
assertThat(action.slot()).isNotNull();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.notification.NotificationController;
|
||||
import stirling.software.proprietary.notification.NotificationService;
|
||||
import stirling.software.proprietary.notification.NotificationView;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Reporting a client-side retry that worked: the bell's one write. Lives beside the failure tests
|
||||
* because the invariants are failure invariants: the bell holds only the prefixed notification id,
|
||||
* so it cannot hand a raw event id to a failure endpoint, and the same service that scopes the
|
||||
* queue decides whose row a caller may close.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("reporting a client-side retry that worked")
|
||||
class NotificationResolveTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
private static final String ACTOR = "reviewer@example.com";
|
||||
|
||||
@Mock private PolicyManagementAuthority authority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private FileRunEventStore store;
|
||||
private FileRunEventService failures;
|
||||
private NotificationController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
|
||||
failures =
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
props);
|
||||
controller = new NotificationController(new NotificationService(failures));
|
||||
|
||||
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
lenient().when(authority.canEditPolicies()).thenReturn(true);
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
|
||||
}
|
||||
|
||||
private FileRunEvent given(FailureKind kind, String actor, String fileId) {
|
||||
return store.record(RecordFailure.forEditor(kind, TEAM, actor, fileId, "boom"));
|
||||
}
|
||||
|
||||
/** The status a refused call came back with. Fails the test if the call was allowed. */
|
||||
private HttpStatus statusOf(Runnable call) {
|
||||
try {
|
||||
call.run();
|
||||
} catch (ResponseStatusException e) {
|
||||
return HttpStatus.valueOf(e.getStatusCode().value());
|
||||
}
|
||||
throw new AssertionError("expected the call to be refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesTheRowBehindThePrefixedId() {
|
||||
// Why the route exists: a successful retry has to close its row, and the bell
|
||||
// has no raw id to close it with.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
NotificationView resolved = controller.resolved("failure:" + event.id());
|
||||
|
||||
assertThat(resolved.status()).isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRowsOwnIdIsNotANotificationId() {
|
||||
// This mirror exists so no client has to strip the prefix, so an unprefixed id is
|
||||
// refused rather than working by accident because there is only one source today.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved(event.id())))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownSourcePrefixIsABadRequest() {
|
||||
// Not a 404: it was never a notification id, so there is no row to report missing.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved("quota:" + event.id())))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(statusOf(() -> controller.resolved("failure:")))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportingTheSameSuccessTwiceIsNotARefusal() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
NotificationView first = controller.resolved("failure:" + event.id());
|
||||
|
||||
assertThat(controller.resolved("failure:" + event.id()))
|
||||
.isEqualTo(first)
|
||||
.extracting(NotificationView::status)
|
||||
.isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRowAReviewerHasDismissedIsAConflict() {
|
||||
// Their decision stands: a retry reporting in afterwards does not overwrite it.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
failures.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved("failure:" + event.id())))
|
||||
.isEqualTo(HttpStatus.CONFLICT);
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aColleaguesNotificationIsNotFoundForAMember() {
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, "colleague@example.com", "f-1");
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved("failure:" + theirs.id())))
|
||||
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -5058,13 +5058,19 @@ tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interacti
|
||||
title = "PDF Multi Tool"
|
||||
|
||||
[notifications]
|
||||
adoptFailed = "The document was unlocked but could not be opened here. Try the tool directly."
|
||||
empty = "Nothing to report."
|
||||
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 there is nothing to open here."
|
||||
notOnThisDevice = "This document is not on this device, so it cannot be opened here."
|
||||
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]
|
||||
@@ -5077,6 +5083,11 @@ copy = "Copy error"
|
||||
less = "Show less"
|
||||
more = "Show full message"
|
||||
|
||||
[notifications.password]
|
||||
cancel = "Cancel"
|
||||
label = "Document password"
|
||||
working = "Unlocking..."
|
||||
|
||||
[notifications.section]
|
||||
earlier = "Earlier"
|
||||
new = "New"
|
||||
@@ -7393,8 +7404,10 @@ title = "Failures"
|
||||
[portal.failures.action]
|
||||
acknowledge = "Acknowledge"
|
||||
confirm = "Are you sure?"
|
||||
decryptAndRetry = "Decrypt and retry"
|
||||
dismiss = "Dismiss"
|
||||
dismissSkipFile = "Skip this file"
|
||||
retry = "Retry"
|
||||
viewFile = "View file"
|
||||
viewInProcessor = "View in processor"
|
||||
|
||||
|
||||
@@ -162,6 +162,14 @@
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.notification-bell__password {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1, 0.25rem);
|
||||
margin-top: var(--sp-2, 0.5rem);
|
||||
}
|
||||
|
||||
.notification-bell__message {
|
||||
grid-column: 2;
|
||||
margin-top: var(--sp-1, 0.25rem);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MantineProvider } from "@mantine/core";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
NotificationActionSlot,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
// @app/ui Button is a Mantine wrapper, so it needs the provider in the tree.
|
||||
@@ -18,7 +19,8 @@ const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
/**
|
||||
* The bell renders whatever the server sends, and does with each row's actions only what the registry
|
||||
* for this build says it can. Two things are its own and worth pinning: which notifications the user has
|
||||
* already looked at, and how a row behaves around an action (message on failure, re-read on success).
|
||||
* already looked at, and how a row behaves around an action (password first, message on failure, re-read
|
||||
* on success).
|
||||
*/
|
||||
|
||||
const fetchNotifications = vi.fn();
|
||||
@@ -31,18 +33,21 @@ vi.mock("@app/services/notifications", () => ({
|
||||
// availability is a fact of the test rather than of the environment.
|
||||
const h = vi.hoisted(() => ({
|
||||
hasLocalFile: true,
|
||||
retryPayload: { operation: "removePassword" } as unknown,
|
||||
specs: {} as Record<
|
||||
string,
|
||||
{
|
||||
available: (context: unknown) => boolean;
|
||||
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),
|
||||
}));
|
||||
|
||||
// Stands in for the layer that owns the destinations. Core's own registry is empty, so without
|
||||
@@ -75,12 +80,14 @@ const { NotificationBell } =
|
||||
|
||||
function offer(
|
||||
id: string,
|
||||
slot: NotificationActionSlot = "SECONDARY",
|
||||
overrides: Partial<NotificationActionOffer> = {},
|
||||
): NotificationActionOffer {
|
||||
return {
|
||||
id,
|
||||
labelKey: `portal.failures.action.${id.toLowerCase()}`,
|
||||
defaultLabel: id,
|
||||
slot,
|
||||
enabled: true,
|
||||
disabledReasonKey: null,
|
||||
...overrides,
|
||||
@@ -123,6 +130,7 @@ describe("NotificationBell", () => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
h.hasLocalFile = true;
|
||||
h.retryPayload = { operation: "removePassword" };
|
||||
h.specs = {};
|
||||
});
|
||||
|
||||
@@ -359,7 +367,7 @@ describe("NotificationBell", () => {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This document is not on this device, so it cannot be opened here.",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
@@ -376,7 +384,7 @@ describe("NotificationBell", () => {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"This failure is not linked to a specific document, so there is nothing to open here.",
|
||||
"This failure is not linked to a specific document, so it cannot be opened or retried here.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -413,7 +421,7 @@ describe("NotificationBell", () => {
|
||||
notification("a", "Unrecognised failure", {
|
||||
ownership: "UNOWNED",
|
||||
actions: [
|
||||
offer("VIEW_FILE", {
|
||||
offer("VIEW_FILE", "SECONDARY", {
|
||||
enabled: false,
|
||||
disabledReasonKey: "portal.failures.disabled.unattended",
|
||||
}),
|
||||
@@ -443,11 +451,11 @@ describe("NotificationBell", () => {
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Unrecognised failure", {
|
||||
actions: [
|
||||
offer("VIEW_IN_PROCESSOR", {
|
||||
offer("VIEW_IN_PROCESSOR", "SECONDARY", {
|
||||
enabled: false,
|
||||
disabledReasonKey: "portal.failures.disabled.closed",
|
||||
}),
|
||||
offer("VIEW_FILE", {
|
||||
offer("VIEW_FILE", "SECONDARY", {
|
||||
enabled: false,
|
||||
disabledReasonKey: "portal.failures.disabled.closed",
|
||||
}),
|
||||
@@ -468,16 +476,59 @@ describe("NotificationBell", () => {
|
||||
expect(document.querySelector(".notification-bell__actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a failed action in the row instead of leaving the user guessing", async () => {
|
||||
it("asks for the password in the row 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();
|
||||
|
||||
// First click reveals the field rather than running anything.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "DECRYPT_AND_RETRY: Password-protected document",
|
||||
}),
|
||||
);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
|
||||
const field = screen.getByLabelText(
|
||||
"Document password: Password-protected document",
|
||||
);
|
||||
fireEvent.change(field, { target: { value: "hunter2" } });
|
||||
fireEvent.submit(field.closest("form") as HTMLFormElement);
|
||||
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(1));
|
||||
expect(run.mock.calls[0][1]).toBe("hunter2");
|
||||
// The server resolved the incident, so the list is re-read rather than patched here, and the
|
||||
// panel gets out of the way of the document it just produced.
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Password-protected document")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a failed unlock in the row 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 />);
|
||||
@@ -485,15 +536,20 @@ describe("NotificationBell", () => {
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "VIEW_FILE: Password-protected document",
|
||||
name: "DECRYPT_AND_RETRY: Password-protected document",
|
||||
}),
|
||||
);
|
||||
const field = screen.getByLabelText(
|
||||
"Document password: Password-protected document",
|
||||
);
|
||||
fireEvent.change(field, { target: { value: "nope" } });
|
||||
fireEvent.submit(field.closest("form") as HTMLFormElement);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
"textContent",
|
||||
"Could not open",
|
||||
"Wrong password",
|
||||
);
|
||||
// Still on screen, so the row remains actionable.
|
||||
// Still on screen, so the user can try another password.
|
||||
expect(screen.getByText("Password-protected document")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { Button, Input } from "@app/ui";
|
||||
import { BellIcon } from "@app/components/notifications/BellIcon";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ClientActionRegistry,
|
||||
type NotificationActionContext,
|
||||
} from "@app/components/notifications/notificationActions";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
@@ -36,7 +37,7 @@ import "@app/components/notifications/NotificationBell.css";
|
||||
*/
|
||||
export function NotificationBell() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen, refresh } =
|
||||
useNotifications();
|
||||
const registry = useNotificationActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -180,6 +181,7 @@ export function NotificationBell() {
|
||||
documentState={documentStateFor(notification)}
|
||||
registry={registry}
|
||||
onDismissPanel={() => setOpen(false)}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -214,12 +216,12 @@ function noteFor(
|
||||
if (!notification.fileId)
|
||||
return t(
|
||||
"notifications.noDocumentLinked",
|
||||
"This failure is not linked to a specific document, so there is nothing to open here.",
|
||||
"This failure is not linked to a specific document, so it cannot be opened or retried here.",
|
||||
);
|
||||
return isResolvableHere(notification)
|
||||
? t(
|
||||
"notifications.notOnThisDevice",
|
||||
"This document is not on this device, so it cannot be opened here.",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
)
|
||||
: null;
|
||||
}
|
||||
@@ -230,11 +232,13 @@ interface NotificationItemProps {
|
||||
documentState: NotificationDocumentState;
|
||||
registry: ClientActionRegistry;
|
||||
onDismissPanel: () => void;
|
||||
/** The row changed something server-side; re-read rather than patch a local copy. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row. Its own component because the message its last attempt came back with and whether that
|
||||
* message is expanded are per-row state the panel cannot hold.
|
||||
* One row. Its own component because the password it is collecting, the message its last attempt came
|
||||
* back with and whether that message is expanded are all per-row state the panel cannot hold.
|
||||
*/
|
||||
function NotificationItem({
|
||||
notification,
|
||||
@@ -242,8 +246,13 @@ function NotificationItem({
|
||||
documentState,
|
||||
registry,
|
||||
onDismissPanel,
|
||||
onChanged,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
// Held only while the field is open, and dropped as soon as the row is done with it. Never stashed,
|
||||
// never logged.
|
||||
const [password, setPassword] = useState("");
|
||||
const [askingFor, setAskingFor] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -253,27 +262,19 @@ function NotificationItem({
|
||||
const context: NotificationActionContext = {
|
||||
notification,
|
||||
hasLocalFile: documentState.hasLocalFile,
|
||||
retryPayload: documentState.retryPayload,
|
||||
};
|
||||
|
||||
// What this device can actually do, in the order the kind declared. An id this build has never
|
||||
// heard of is skipped rather than rendered unwired: the server ships new kinds, and new actions,
|
||||
// ahead of the clients that understand them.
|
||||
const usable = notification.actions.filter((offer) => {
|
||||
if (!offer.enabled) return false;
|
||||
const spec = registry[offer.id];
|
||||
return spec ? spec.available(context) : false;
|
||||
});
|
||||
|
||||
// Why the row is thin, when the server withheld something and said so. Only from an action this
|
||||
// build would otherwise have rendered, so a reason about an action it cannot perform anyway is not
|
||||
// presented as the row's explanation.
|
||||
const withheldReasonKey =
|
||||
notification.actions.find(
|
||||
(offer) =>
|
||||
!offer.enabled &&
|
||||
offer.disabledReasonKey !== null &&
|
||||
registry[offer.id] !== undefined,
|
||||
)?.disabledReasonKey ?? null;
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
notification.actions,
|
||||
(offer) => {
|
||||
const spec = registry[offer.id];
|
||||
// An id this build has never heard of: skipped, not rendered unwired. The server ships new kinds
|
||||
// and their actions ahead of the clients that understand them.
|
||||
if (!spec) return false;
|
||||
return spec.available(context);
|
||||
},
|
||||
);
|
||||
|
||||
const labelOf = (offer: NotificationActionOffer) =>
|
||||
t(offer.labelKey, offer.defaultLabel);
|
||||
@@ -284,9 +285,18 @@ function NotificationItem({
|
||||
|
||||
const spec = registry[offer.id];
|
||||
if (!spec) return;
|
||||
// First click on a password action opens the field, the second one runs it. An empty field means
|
||||
// the user clicked the button again rather than filling it in, so there is nothing to send yet.
|
||||
if (spec.needsPassword && (askingFor !== offer.id || password === "")) {
|
||||
setAskingFor(offer.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(offer.id);
|
||||
const outcome = await spec.run(context);
|
||||
const outcome = await spec.run(
|
||||
context,
|
||||
spec.needsPassword ? password : undefined,
|
||||
);
|
||||
setBusy(null);
|
||||
if (outcome && !outcome.ok) {
|
||||
setMessage(
|
||||
@@ -299,6 +309,10 @@ function NotificationItem({
|
||||
return;
|
||||
}
|
||||
|
||||
setPassword("");
|
||||
setAskingFor(null);
|
||||
// A password action resolves the incident server-side, so the row is expected to drop out.
|
||||
if (spec.needsPassword) onChanged();
|
||||
if (spec.closesPanel) onDismissPanel();
|
||||
};
|
||||
|
||||
@@ -312,6 +326,10 @@ function NotificationItem({
|
||||
}
|
||||
};
|
||||
|
||||
const asking = askingFor
|
||||
? (notification.actions.find((offer) => offer.id === askingFor) ?? null)
|
||||
: null;
|
||||
|
||||
const note = noteFor(notification, documentState, withheldReasonKey, t);
|
||||
|
||||
return (
|
||||
@@ -381,15 +399,30 @@ function NotificationItem({
|
||||
{/* Actions were taken away from this row, so say why rather than leaving a bare row. */}
|
||||
{note && <span className="notification-bell__note">{note}</span>}
|
||||
|
||||
{/* Every action the row has, in the kind's declared order, the first leading. Three is the most
|
||||
any kind offers once the unusable ones are dropped, so hiding the tail behind a menu would
|
||||
cost more than it saves. */}
|
||||
{usable.length > 0 && (
|
||||
{/* Every action the row has, in the server's order. Three is the most any kind offers once the
|
||||
unusable ones are dropped, so hiding the tail behind a menu would cost more than it saves. */}
|
||||
{primary && (
|
||||
<span className="notification-bell__actions">
|
||||
{usable.map((offer, index) => (
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
rowTitle={title}
|
||||
label={labelOf(primary)}
|
||||
busy={busy === primary.id}
|
||||
onRun={() => void run(primary)}
|
||||
/>
|
||||
{secondary && (
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
rowTitle={title}
|
||||
label={labelOf(secondary)}
|
||||
busy={busy === secondary.id}
|
||||
onRun={() => void run(secondary)}
|
||||
/>
|
||||
)}
|
||||
{overflow.map((offer) => (
|
||||
<ActionButton
|
||||
key={offer.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
variant="tertiary"
|
||||
rowTitle={title}
|
||||
label={labelOf(offer)}
|
||||
busy={busy === offer.id}
|
||||
@@ -399,6 +432,51 @@ function NotificationItem({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{asking && (
|
||||
<form
|
||||
className="notification-bell__password"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void run(asking);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
inputSize="sm"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={password}
|
||||
aria-label={`${t("notifications.password.label", "Document password")}: ${title}`}
|
||||
placeholder={t("notifications.password.label", "Document password")}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
disabled={password === "" || busy !== null}
|
||||
>
|
||||
{busy === asking.id
|
||||
? t("notifications.password.working", "Unlocking...")
|
||||
: labelOf(asking)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
onClick={() => {
|
||||
setPassword("");
|
||||
setAskingFor(null);
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
{t("notifications.password.cancel", "Cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<span className="notification-bell__message" role="alert">
|
||||
{message}
|
||||
@@ -410,7 +488,7 @@ function NotificationItem({
|
||||
|
||||
interface ActionButtonProps {
|
||||
/** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */
|
||||
variant: "primary" | "secondary";
|
||||
variant: "primary" | "secondary" | "tertiary";
|
||||
rowTitle: string;
|
||||
label: string;
|
||||
busy: boolean;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
import type {
|
||||
NotificationActionOffer,
|
||||
NotificationActionSlot,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
/**
|
||||
* The one rule that decides how loud a notification is allowed to be. Pinned against the shapes the
|
||||
* server actually sends for the two failure kinds that exist, because the promotions are only
|
||||
* correct in combination: what is left over depends on what won the buttons.
|
||||
*
|
||||
* Dispositions such as Dismiss never appear here: the projection carries only the actions the
|
||||
* client itself runs, so the bell is never handed a button it would refuse to draw.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The offers as `FailureKind` declares them. An unrecognised failure has no known fix, so its retry
|
||||
* is only ever a supporting action; a password failure has one, and its plain retry drops in behind
|
||||
* the unlock.
|
||||
*/
|
||||
const UNKNOWN_OFFERS: Record<string, NotificationActionOffer> = {
|
||||
RETRY: offer("RETRY", "SECONDARY"),
|
||||
VIEW_IN_PROCESSOR: offer("VIEW_IN_PROCESSOR", "SECONDARY"),
|
||||
VIEW_FILE: offer("VIEW_FILE", "OVERFLOW"),
|
||||
};
|
||||
|
||||
const PASSWORD_OFFERS: Record<string, NotificationActionOffer> = {
|
||||
DECRYPT_AND_RETRY: offer("DECRYPT_AND_RETRY", "RESOLUTION"),
|
||||
RETRY: offer("RETRY", "OVERFLOW"),
|
||||
VIEW_FILE: offer("VIEW_FILE", "OVERFLOW"),
|
||||
VIEW_IN_PROCESSOR: offer("VIEW_IN_PROCESSOR", "SECONDARY"),
|
||||
};
|
||||
|
||||
/** The reasons the server sends with an action it would refuse. */
|
||||
const NO_DOCUMENT = "portal.failures.disabled.noDocument";
|
||||
const UNATTENDED = "portal.failures.disabled.unattended";
|
||||
const CLOSED = "portal.failures.disabled.closed";
|
||||
|
||||
function offer(
|
||||
id: string,
|
||||
slot: NotificationActionSlot,
|
||||
overrides: Partial<NotificationActionOffer> = {},
|
||||
): NotificationActionOffer {
|
||||
return {
|
||||
id,
|
||||
labelKey: `portal.failures.action.${id.toLowerCase()}`,
|
||||
defaultLabel: id,
|
||||
slot,
|
||||
enabled: true,
|
||||
disabledReasonKey: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function from(
|
||||
declared: Record<string, NotificationActionOffer>,
|
||||
ids: string[],
|
||||
): NotificationActionOffer[] {
|
||||
return ids.map((id) => {
|
||||
const found = declared[id];
|
||||
if (!found) throw new Error(`That kind offers no ${id}`);
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
/** What the server sends for an unrecognised failure, for a reader offered these actions. */
|
||||
const unknown = (...ids: string[]) => from(UNKNOWN_OFFERS, ids);
|
||||
|
||||
/** The same for a password-protected one. */
|
||||
const password = (...ids: string[]) => from(PASSWORD_OFFERS, ids);
|
||||
|
||||
/** The same offers, with the named ones marked as the server would refuse them, and why. */
|
||||
function refusing(
|
||||
offers: NotificationActionOffer[],
|
||||
reasonKey: string,
|
||||
...ids: string[]
|
||||
): NotificationActionOffer[] {
|
||||
return offers.map((action) =>
|
||||
ids.includes(action.id)
|
||||
? { ...action, enabled: false, disabledReasonKey: reasonKey }
|
||||
: action,
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything this client can do, as the registry would answer with the file on this device. */
|
||||
const RUNNABLE = new Set([
|
||||
"RETRY",
|
||||
"DECRYPT_AND_RETRY",
|
||||
"VIEW_FILE",
|
||||
"VIEW_IN_PROCESSOR",
|
||||
]);
|
||||
|
||||
/** The predicate the bell supplies: a known id, on a device that can act on it. */
|
||||
const canRun = (action: NotificationActionOffer) => RUNNABLE.has(action.id);
|
||||
|
||||
function promoted(list: NotificationActionOffer[]) {
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
list,
|
||||
canRun,
|
||||
);
|
||||
return {
|
||||
primary: primary?.id ?? null,
|
||||
secondary: secondary?.id ?? null,
|
||||
overflow: overflow.map((action) => action.id),
|
||||
withheldReasonKey,
|
||||
};
|
||||
}
|
||||
|
||||
describe("promoteActions", () => {
|
||||
it("gives the owner the retry, and keeps the rest quiet behind it", () => {
|
||||
// No portal access, so the server never offered the processor link.
|
||||
expect(promoted(unknown("RETRY", "VIEW_FILE"))).toEqual({
|
||||
primary: "RETRY",
|
||||
secondary: null,
|
||||
overflow: ["VIEW_FILE"],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leads an attended policy failure with the queue, and states what was refused", () => {
|
||||
// The document is not the reader's to open, so a greyed unlock would be false hope: the row loses
|
||||
// the buttons and keeps the explanation.
|
||||
expect(
|
||||
promoted(
|
||||
refusing(
|
||||
unknown("RETRY", "VIEW_IN_PROCESSOR", "VIEW_FILE"),
|
||||
NO_DOCUMENT,
|
||||
"RETRY",
|
||||
"VIEW_FILE",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: NO_DOCUMENT,
|
||||
});
|
||||
});
|
||||
|
||||
it("leads an unattended failure with the queue, and says retrying is not available", () => {
|
||||
// Nobody holds the document, so retrying is coming rather than missing. One reason for the row,
|
||||
// taken from the best thing it lost.
|
||||
expect(
|
||||
promoted(
|
||||
refusing(
|
||||
unknown("RETRY", "VIEW_IN_PROCESSOR", "VIEW_FILE"),
|
||||
UNATTENDED,
|
||||
"RETRY",
|
||||
"VIEW_FILE",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: UNATTENDED,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains nothing on a colleague's failure, having taken nothing away", () => {
|
||||
// Not their document, so nothing that needs the bytes was offered at all. There is no loss to
|
||||
// account for, and a note would only puzzle the reader.
|
||||
expect(promoted(unknown("VIEW_IN_PROCESSOR"))).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leads a password failure with the unlock, not the plain retry", () => {
|
||||
// Running it again unchanged is a second answer to the same problem, so it drops behind.
|
||||
expect(promoted(password("DECRYPT_AND_RETRY", "RETRY", "VIEW_FILE"))).toEqual(
|
||||
{
|
||||
primary: "DECRYPT_AND_RETRY",
|
||||
secondary: null,
|
||||
overflow: ["RETRY", "VIEW_FILE"],
|
||||
withheldReasonKey: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("gives a reviewer their own password failure the unlock plus the queue", () => {
|
||||
expect(
|
||||
promoted(
|
||||
password("DECRYPT_AND_RETRY", "RETRY", "VIEW_FILE", "VIEW_IN_PROCESSOR"),
|
||||
),
|
||||
).toEqual({
|
||||
primary: "DECRYPT_AND_RETRY",
|
||||
secondary: "VIEW_IN_PROCESSOR",
|
||||
overflow: ["RETRY", "VIEW_FILE"],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a closed row no buttons at all, only its reason", () => {
|
||||
// Already resolved elsewhere: every offer is refused, so the row is its message plus one line
|
||||
// saying why there is nothing left to do.
|
||||
expect(
|
||||
promoted(
|
||||
refusing(unknown("RETRY", "VIEW_FILE"), CLOSED, "RETRY", "VIEW_FILE"),
|
||||
),
|
||||
).toEqual({
|
||||
primary: null,
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: CLOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes past a resolution the shell cannot deliver", () => {
|
||||
// The owner reading their own password failure from the processor: that shell has no FileContext,
|
||||
// so the unlock has nowhere to put its output and reports itself unavailable. What is left is
|
||||
// coherent on its own - the queue becomes the row's button.
|
||||
const inProcessor = (action: NotificationActionOffer) =>
|
||||
action.id !== "DECRYPT_AND_RETRY" && canRun(action);
|
||||
|
||||
const { primary, secondary, overflow } = promoteActions(
|
||||
password("DECRYPT_AND_RETRY", "RETRY", "VIEW_FILE", "VIEW_IN_PROCESSOR"),
|
||||
inProcessor,
|
||||
);
|
||||
|
||||
expect(primary?.id).toBe("VIEW_IN_PROCESSOR");
|
||||
expect(secondary).toBeNull();
|
||||
expect(overflow.map((action) => action.id)).toEqual(["RETRY", "VIEW_FILE"]);
|
||||
});
|
||||
|
||||
it("drops a client action this device cannot perform, without inventing a reason", () => {
|
||||
// The document is gone from this browser: the actions disappear rather than failing on click. The
|
||||
// server withheld nothing, so the row has no server reason and the bell falls back to what this
|
||||
// device knows.
|
||||
const { primary, overflow, withheldReasonKey } = promoteActions(
|
||||
unknown("RETRY", "VIEW_FILE"),
|
||||
() => false,
|
||||
);
|
||||
|
||||
expect(primary).toBeNull();
|
||||
expect(overflow).toEqual([]);
|
||||
expect(withheldReasonKey).toBeNull();
|
||||
});
|
||||
|
||||
it("skips an action id it has never heard of without touching the rest", () => {
|
||||
// The server ships a kind with a new action before this build knows what it means.
|
||||
const list = [offer("QUARANTINE", "RESOLUTION"), ...unknown("RETRY")];
|
||||
|
||||
expect(promoted(list)).toEqual({
|
||||
primary: "RETRY",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("has nothing to promote when nothing survives", () => {
|
||||
expect(promoteActions([], () => true)).toEqual({
|
||||
primary: null,
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the reason from the best action lost, not the first declared", () => {
|
||||
// Two refusals, one row: the reader gets the one attached to the action they would have reached
|
||||
// for first.
|
||||
const list = [
|
||||
offer("VIEW_FILE", "OVERFLOW", {
|
||||
enabled: false,
|
||||
disabledReasonKey: CLOSED,
|
||||
}),
|
||||
offer("DECRYPT_AND_RETRY", "RESOLUTION", {
|
||||
enabled: false,
|
||||
disabledReasonKey: NO_DOCUMENT,
|
||||
}),
|
||||
...password("VIEW_IN_PROCESSOR"),
|
||||
];
|
||||
|
||||
expect(promoted(list).withheldReasonKey).toBe(NO_DOCUMENT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
NotificationActionOffer,
|
||||
NotificationActionSlot,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
/**
|
||||
* Where each of a row's actions ends up on screen. The server says what an action does and how much of
|
||||
* the row it has earned; this turns that into an order and a prominence. A pure function because it is
|
||||
* the one piece of the bell that decides prominence, and every failure kind goes through it.
|
||||
*/
|
||||
|
||||
const SLOT_RANK: Record<NotificationActionSlot, number> = {
|
||||
RESOLUTION: 0,
|
||||
SECONDARY: 1,
|
||||
OVERFLOW: 2,
|
||||
};
|
||||
|
||||
export interface PromotedActions {
|
||||
/** The row's own button. Null when nothing survived the filter. */
|
||||
primary: NotificationActionOffer | null;
|
||||
/** A second button, only ever an action the server marked SECONDARY. */
|
||||
secondary: NotificationActionOffer | null;
|
||||
/** Everything else, in the server's order, for the row to render quietly after those two. */
|
||||
overflow: NotificationActionOffer[];
|
||||
/**
|
||||
* The reason the server gave for the best action it withheld, for the row to state once. Null when
|
||||
* it withheld nothing, or gave no reason.
|
||||
*/
|
||||
withheldReasonKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a row's offers into one primary button, at most one secondary button, and the quiet rest.
|
||||
*
|
||||
* Every offer the bell is given is one this client runs itself, so each is asked past
|
||||
* `canRenderClientAction`: whether this build knows the id, and whether this device can currently
|
||||
* perform it.
|
||||
*
|
||||
* A dropped action leaves no hole, and a disabled one is dropped too: a button that can never work is
|
||||
* false hope. Its reason comes back instead, for the row to say in words.
|
||||
*/
|
||||
export function promoteActions(
|
||||
offers: readonly NotificationActionOffer[],
|
||||
canRenderClientAction: (offer: NotificationActionOffer) => boolean,
|
||||
): PromotedActions {
|
||||
const ranked = offers
|
||||
.map((offer, declaredAt) => ({ offer, declaredAt }))
|
||||
// Slot first, then declaration order, so two actions in one slot keep the server's ranking.
|
||||
.sort(
|
||||
(a, b) =>
|
||||
SLOT_RANK[a.offer.slot] - SLOT_RANK[b.offer.slot] ||
|
||||
a.declaredAt - b.declaredAt,
|
||||
)
|
||||
.map(({ offer }) => offer);
|
||||
|
||||
// The best one withheld, so a row explains itself once rather than once per lost action.
|
||||
const withheldReasonKey =
|
||||
ranked.find((offer) => !offer.enabled && offer.disabledReasonKey)
|
||||
?.disabledReasonKey ?? null;
|
||||
|
||||
const renderable = ranked.filter(
|
||||
(offer) => offer.enabled && canRenderClientAction(offer),
|
||||
);
|
||||
|
||||
const [primary, next, ...rest] = renderable;
|
||||
if (!primary)
|
||||
return { primary: null, secondary: null, overflow: [], withheldReasonKey };
|
||||
|
||||
// Only if the server ranked it SECONDARY: a second RESOLUTION would read as two answers to the same
|
||||
// problem, and an OVERFLOW one was ranked below the row's own buttons by the server itself.
|
||||
const secondary = next?.slot === "SECONDARY" ? next : null;
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
overflow: secondary ? rest : next ? [next, ...rest] : rest,
|
||||
withheldReasonKey,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppNotification } from "@app/services/notifications";
|
||||
import type { RetryPayload } from "@app/services/notificationRetry";
|
||||
|
||||
/**
|
||||
* What this client can do about a notification, keyed by the action id the server offered. Keyed by
|
||||
@@ -11,6 +12,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,10 +29,13 @@ export interface ClientActionOutcome {
|
||||
export interface ClientActionSpec {
|
||||
/** Whether this device can perform it right now. Asked per row, never during a request. */
|
||||
available(context: NotificationActionContext): boolean;
|
||||
/** May answer synchronously. */
|
||||
/** `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 in the row before running. Never stored, never logged. */
|
||||
needsPassword?: boolean;
|
||||
/** Whether the panel should get out of the way, because the destination is behind it. */
|
||||
closesPanel?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import { FILE_EVENTS } from "@app/services/errorUtils";
|
||||
import { reportToolFailure } from "@app/services/failureReporting";
|
||||
import {
|
||||
reportToolFailure,
|
||||
wasCancelled,
|
||||
} from "@app/services/failureReporting";
|
||||
import { stashRetryPayload } from "@app/services/notificationRetry";
|
||||
import { refreshNotificationsNow } from "@app/hooks/useNotifications";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { getFilenameWithoutExtension } from "@app/utils/fileUtils";
|
||||
@@ -615,6 +619,20 @@ export const useToolOperation = <TParams>(
|
||||
fileIds: validFiles.map((file) => file.fileId),
|
||||
}).then(refreshNotificationsNow);
|
||||
|
||||
// Keep what a retry would need, since the report itself carries no
|
||||
// operation and answers 204. Gated on the reporter's own cancellation
|
||||
// test so the two cannot disagree about what counts as a failure, and on
|
||||
// there being an endpoint: a custom processor has nothing to re-submit to.
|
||||
if (!wasCancelled(error) && runtimeEndpoint) {
|
||||
void stashRetryPayload({
|
||||
operation: config.operationType,
|
||||
endpoint: runtimeEndpoint,
|
||||
params: params as Record<string, unknown>,
|
||||
fileIds: validFiles.map((file) => file.fileId),
|
||||
recordedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
|
||||
@@ -18,9 +18,11 @@ vi.mock("@app/services/notifications", () => ({
|
||||
// The document lookups read IndexedDB, which jsdom has none of. Counted here so that "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 { useNotifications } = await import("@app/hooks/useNotifications");
|
||||
@@ -56,6 +58,7 @@ describe("useNotifications", () => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
hasLocalFile.mockClear();
|
||||
loadRetryPayload.mockClear();
|
||||
});
|
||||
|
||||
it("reads the list once however many bells are mounted", async () => {
|
||||
@@ -82,6 +85,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";
|
||||
|
||||
/**
|
||||
* The caller's notifications, refreshed on a timer because they arrive from background work rather
|
||||
@@ -50,10 +54,12 @@ function writeLastSeenId(id: string): void {
|
||||
*/
|
||||
export interface NotificationDocumentState {
|
||||
hasLocalFile: boolean;
|
||||
retryPayload: RetryPayload | null;
|
||||
}
|
||||
|
||||
const NO_DOCUMENT: NotificationDocumentState = {
|
||||
hasLocalFile: false,
|
||||
retryPayload: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,6 +138,7 @@ async function read(forCycle: number): Promise<void> {
|
||||
fileId,
|
||||
{
|
||||
hasLocalFile: await hasLocalFile(fileId),
|
||||
retryPayload: await loadRetryPayload(fileId),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
|
||||
@@ -165,8 +165,11 @@ function messageOf(error: unknown): string {
|
||||
* <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.
|
||||
*
|
||||
* <p>Exported for `useToolOperation`, which must not stash a retry for a run the user
|
||||
* cancelled themselves.
|
||||
*/
|
||||
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,21 +0,0 @@
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* Whether the document a failure was filed against is still in this browser. It decides whether the
|
||||
* bell can offer to open it: the id is this workspace's own, so no other device can answer yes.
|
||||
*/
|
||||
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,333 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { indexedDBManager } from "@app/services/indexedDBManager";
|
||||
|
||||
/**
|
||||
* Tests for the notification bell's retry stash. Three properties matter: a
|
||||
* retry offered by the bell can still find what it needs after a reload, the
|
||||
* store cannot grow without bound, and a password never lands in it.
|
||||
*/
|
||||
|
||||
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"],
|
||||
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"],
|
||||
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");
|
||||
// No password-shaped field survives either. 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 of the parameters 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 () => {
|
||||
// Deliberately only a little past the limit, so the truncated subtree is small enough to store.
|
||||
// A far deeper object would fail to store for unrelated reasons and pass this vacuously.
|
||||
let past: Record<string, unknown> = { password: "hunter2" };
|
||||
for (let i = 0; i < 25; i++) past = { down: past };
|
||||
|
||||
await stashRetryPayload(payload({ params: { past } }));
|
||||
|
||||
// The point where the walk gives up is the one place it must not hand back a subtree it never
|
||||
// examined: returning the value there would persist every secret below the limit.
|
||||
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);
|
||||
expect(result.message).toBeTruthy();
|
||||
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 - an attended policy run, whose notification
|
||||
* names the document and needs nothing else. Same request, 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.message).toBeTruthy();
|
||||
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,370 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import {
|
||||
indexedDBManager,
|
||||
type DatabaseConfig,
|
||||
} from "@app/services/indexedDBManager";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
/**
|
||||
* What the notification bell needs to offer "Retry" or "Decrypt and retry" on a
|
||||
* failure the editor reported. The server keeps none of it: the report drops the
|
||||
* operation and answers 204, so this lives here, keyed on the opaque `fileId` it was
|
||||
* filed against. Last-write-wins per fileId, matching the server's actor|kind|file
|
||||
* dedup: one file failing two operations is one incident with one retry button.
|
||||
*/
|
||||
export interface RetryPayload {
|
||||
/** tool/endpoint identifier, e.g. "remove-password" */
|
||||
operation: string;
|
||||
/** the API path that failed, so a retry needs no tool registry lookup */
|
||||
endpoint: string;
|
||||
/** the tool parameters as submitted */
|
||||
params: Record<string, unknown>;
|
||||
fileIds: string[];
|
||||
recordedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own database rather than a store on `stirling-pdf-files`: that schema has
|
||||
* shipped at v9, and adding a store there means a version bump plus an upgrade path
|
||||
* on every install for a hint that is safe to lose. Still opened through
|
||||
* `indexedDBManager`, so this is not a second way to reach IndexedDB.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret-looking field names. A tool's parameters can carry one (remove-password submits
|
||||
* `password`), so they are stripped on the way in rather than trusted to be absent.
|
||||
*/
|
||||
const SECRET_FIELD = /pass(word|phrase)|secret|token|credential/i;
|
||||
|
||||
/**
|
||||
* Stash the retry payload for a failure that was just reported. Never rejects, like
|
||||
* `reportToolFailure`: a browser that refuses IndexedDB should cost the user the retry
|
||||
* button, not a second error on top of the failure they already have.
|
||||
*/
|
||||
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,
|
||||
// Persisting a password would defeat the point of asking for it again.
|
||||
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;
|
||||
|
||||
// A record written by an older shape of this service is unusable rather than
|
||||
// half-usable: a retry with no endpoint has nowhere to go.
|
||||
if (!record.operation || !record.endpoint) return null;
|
||||
|
||||
return {
|
||||
operation: record.operation,
|
||||
endpoint: record.endpoint,
|
||||
params: record.params ?? {},
|
||||
fileIds: record.fileIds ?? [fileId],
|
||||
recordedAt: record.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the document is still in this browser, which decides whether a retry can run at
|
||||
* all. Null once the user deletes the file, and on every other device they own.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** What a password-carrying call comes back with. `files` only ever on success. */
|
||||
export interface PasswordRetryOutcome {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
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 document this browser is holding, for a failure with no stashed operation such
|
||||
* as an attended policy run: a password-protected input is fixed the same way whatever was
|
||||
* reading it. Same contract as {@link retryWithPassword} otherwise.
|
||||
*/
|
||||
export async function unlockLocalDocument(
|
||||
fileId: string,
|
||||
password: string,
|
||||
): Promise<PasswordRetryOutcome> {
|
||||
return postWithPassword(UNLOCK_ENDPOINT, {}, [fileId], password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the stashed operation with the password the user just typed, and hand back
|
||||
* what it produced. The password is appended to a single request and then out of
|
||||
* scope: never stashed, never logged, never in the message returned here.
|
||||
*
|
||||
* `files` is returned rather than adopted because every file operation goes through
|
||||
* FileContext, which a service cannot reach.
|
||||
*/
|
||||
export async function retryWithPassword(
|
||||
payload: RetryPayload,
|
||||
password: string,
|
||||
): Promise<PasswordRetryOutcome> {
|
||||
if (!payload.endpoint) {
|
||||
return { ok: false, message: "This operation cannot be retried." };
|
||||
}
|
||||
|
||||
return postWithPassword(
|
||||
payload.endpoint,
|
||||
payload.params,
|
||||
payload.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[],
|
||||
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. Resolved rather than thrown: the caller shows this next to the notification.
|
||||
if (files.length === 0 || files.length !== fileIds.length) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"This file is no longer stored in this browser, so it cannot be retried here.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = toFormData(params, files);
|
||||
formData.append("password", password);
|
||||
const response = await apiClient.post<Blob>(endpoint, formData, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
files: [
|
||||
{
|
||||
blob: response.data,
|
||||
filename: filenameOf(response.headers, files[0].name),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, message: messageOf(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The name the server gave the output, falling back to the input's: a caller adopting an
|
||||
* unnamed blob would put a file called "blob" in the user's workbench.
|
||||
*/
|
||||
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, percent-encoded) wins over plain filename, which is how a
|
||||
// server sends a non-ASCII name.
|
||||
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() !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's parameters as form fields, alongside the documents under `fileInput`.
|
||||
* `objectToFormData` is not reused: it is typed to the generated request union and throws on
|
||||
* anything non-primitive, whereas a stashed payload is an opaque record read out of storage.
|
||||
*/
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How deep the walk below goes before it stops descending. Tool parameters are shallow, so this is
|
||||
* far above anything real; it exists so a pathological or cyclic object cannot exhaust the stack.
|
||||
*/
|
||||
const MAX_PARAM_DEPTH = 20;
|
||||
|
||||
/** Stands in for a subtree too deep to walk. Never the value itself: see below. */
|
||||
const TOO_DEEP = "[nested too deeply to store]";
|
||||
|
||||
/**
|
||||
* Every secret-looking field dropped, at any depth: a tool can nest its parameters, and a
|
||||
* password one level down is still a password.
|
||||
*
|
||||
* Past {@link MAX_PARAM_DEPTH} the subtree is replaced rather than returned. Returning it would
|
||||
* mean anything below the limit is persisted unexamined, so the one place this function must not
|
||||
* fail open is exactly the place it stops looking.
|
||||
*/
|
||||
function withoutSecrets(
|
||||
value: Record<string, unknown>,
|
||||
): Record<string, unknown>;
|
||||
function withoutSecrets(value: unknown): unknown;
|
||||
function withoutSecrets(value: unknown): unknown {
|
||||
return prunedBelow(value, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The walk itself. Separate from {@link withoutSecrets} because the depth is bookkeeping between
|
||||
* one level and the next, and no caller should be able to start part-way down.
|
||||
*/
|
||||
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 user saw. Never carries the password: it is not interpolated here. */
|
||||
function messageOf(error: unknown): string {
|
||||
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
|
||||
: "Retrying the operation failed.";
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Evict in the same transaction as the writes, so two concurrent stashes cannot
|
||||
// both decide the store is under the cap.
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export type NotificationOrigin = "TOOL" | "POLICY" | "PIPELINE";
|
||||
*/
|
||||
export type NotificationOwnership = "MINE" | "THEIRS" | "UNOWNED";
|
||||
|
||||
/**
|
||||
* How much of the row an action has earned. The server ranks by what the action does, not by where it
|
||||
* ends up on screen; `promoteActions` turns a slot into a button or a menu entry.
|
||||
*/
|
||||
export type NotificationActionSlot = "RESOLUTION" | "SECONDARY" | "OVERFLOW";
|
||||
|
||||
/**
|
||||
* One action as offered for one notification, all of them run by this client on its own device.
|
||||
* `id` is a plain string rather than a union because the server may know actions this build does
|
||||
@@ -32,6 +38,7 @@ export interface NotificationActionOffer {
|
||||
labelKey: string;
|
||||
/** English fallback, for a build with no copy for `labelKey`. */
|
||||
defaultLabel: string;
|
||||
slot: NotificationActionSlot;
|
||||
/** False means it cannot work for this row. The bell renders no button and states the reason
|
||||
* instead; the portal's queue still shows it disabled. */
|
||||
enabled: boolean;
|
||||
@@ -91,3 +98,24 @@ export async function fetchNotifications(
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the server that the client fixed what a notification was about, so the bell stops reporting a
|
||||
* failure the user has already dealt with. Takes the prefixed id, so the bell never hands a raw row id
|
||||
* to a failure endpoint.
|
||||
*
|
||||
* Never throws. A refusal (already dismissed, not the caller's row) is not worth interrupting the user
|
||||
* over: their document is already fixed and in front of them, and the next read tidies up the row.
|
||||
*/
|
||||
export async function reportNotificationResolved(
|
||||
notificationId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await apiClient.post(
|
||||
`${NOTIFICATIONS_PATH}/${encodeURIComponent(notificationId)}/resolved`,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+540
-4
@@ -14,6 +14,29 @@ import type { NotificationActionContext } from "@core/components/notifications/n
|
||||
* are the interesting cases: selecting the document directly, or handing it over.
|
||||
*/
|
||||
|
||||
const retryWithPassword = vi.fn();
|
||||
const unlockLocalDocument = vi.fn();
|
||||
vi.mock("@app/services/notificationRetry", () => ({
|
||||
retryWithPassword: (...args: unknown[]) => retryWithPassword(...args),
|
||||
unlockLocalDocument: (...args: unknown[]) => unlockLocalDocument(...args),
|
||||
}));
|
||||
|
||||
const rerunPolicy = vi.fn();
|
||||
const rerunPolicyOnDocument = vi.fn();
|
||||
vi.mock("@app/services/notificationPolicyRetry", () => ({
|
||||
rerunPolicy: (...args: unknown[]) => rerunPolicy(...args),
|
||||
rerunPolicyOnDocument: (...args: unknown[]) => rerunPolicyOnDocument(...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 () => ({
|
||||
...(await vi.importActual<typeof import("react-router-dom")>(
|
||||
@@ -57,6 +80,8 @@ const setActiveFileId = vi.fn();
|
||||
const setWorkbench = vi.fn();
|
||||
/** What the workbench already holds, so the "do not add it twice" path can be exercised. */
|
||||
let openFileIds: string[] = [];
|
||||
const setSelectedFiles = vi.fn();
|
||||
const addFiles = vi.fn();
|
||||
|
||||
function notification(
|
||||
overrides: Partial<AppNotification> = {},
|
||||
@@ -89,6 +114,7 @@ function offer(id: string): NotificationActionOffer {
|
||||
id,
|
||||
labelKey: `portal.failures.action.${id.toLowerCase()}`,
|
||||
defaultLabel: id,
|
||||
slot: "SECONDARY",
|
||||
enabled: true,
|
||||
disabledReasonKey: null,
|
||||
};
|
||||
@@ -100,6 +126,32 @@ function context(
|
||||
return {
|
||||
notification: notification(),
|
||||
hasLocalFile: true,
|
||||
retryPayload: {
|
||||
operation: "removePassword",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
recordedAt: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure of an attended policy run: the editor started it on a document it was holding, so the
|
||||
* row names the policy and that document, and no stash was ever written for it.
|
||||
*/
|
||||
function policyContext(
|
||||
overrides: Partial<NotificationActionContext> = {},
|
||||
): NotificationActionContext {
|
||||
return {
|
||||
notification: notification({
|
||||
origin: "POLICY",
|
||||
policyId: "pol-1",
|
||||
sourceId: null,
|
||||
}),
|
||||
hasLocalFile: true,
|
||||
retryPayload: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -109,7 +161,7 @@ const inEditor = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter>
|
||||
<FileActionsContext.Provider
|
||||
value={{
|
||||
actions: { addStirlingFileStubs } as never,
|
||||
actions: { addStirlingFileStubs, setSelectedFiles, addFiles } as never,
|
||||
dispatch: vi.fn(),
|
||||
}}
|
||||
>
|
||||
@@ -143,6 +195,16 @@ 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([]);
|
||||
@@ -150,15 +212,68 @@ beforeEach(() => {
|
||||
setWorkbench.mockReset();
|
||||
h.getStirlingFileStub.mockReset().mockResolvedValue(h.stub);
|
||||
openFileIds = [];
|
||||
setSelectedFiles.mockReset();
|
||||
// The workspace's own id for the adopted document, which is what a policy re-run's output belongs
|
||||
// to - 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 and produces a document, since almost every case below is about
|
||||
// what happens to it afterwards.
|
||||
unlockLocalDocument.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
files: [
|
||||
{
|
||||
blob: new Blob(["pdf"], { type: "application/pdf" }),
|
||||
filename: "invoice.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
// Tracked by default: the run is in the store, so something is polling it and its output will
|
||||
// arrive. An untracked run is a separate case below, since it changes what the row may claim.
|
||||
rerunPolicy.mockReset().mockResolvedValue({ ok: true, tracked: true });
|
||||
rerunPolicyOnDocument.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 with the document selected", () => {
|
||||
registry().RETRY?.run(context());
|
||||
|
||||
expect(setSelectedFiles).toHaveBeenCalledWith(["f-1"]);
|
||||
// The tool the stashed operation names, so the user sees the settings before it runs again.
|
||||
expect(window.location.pathname).toBe("/remove-password");
|
||||
});
|
||||
|
||||
it("opens the editor itself when the stashed operation names no tool this build has", () => {
|
||||
registry().RETRY?.run(
|
||||
context({
|
||||
retryPayload: {
|
||||
operation: "quarantine",
|
||||
endpoint: "/api/v1/quarantine",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
recordedAt: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(window.location.pathname).toBe("/");
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -241,6 +356,119 @@ describe("useNotificationActions", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
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 as well as added, so it is the document on screen when the panel closes; and marked
|
||||
// in-app so `usePolicyAutoRun` does not enforce the upload chain on it by itself.
|
||||
expect(options).toEqual({ selectFiles: true, derivedFromTool: true });
|
||||
// And the incident is closed, with the prefixed id: nothing else tells the server the client
|
||||
// fixed it, so the bell would otherwise keep reporting a failure the user has dealt with.
|
||||
expect(reportNotificationResolved).toHaveBeenCalledWith("failure:evt-1");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// A reviewer dismissed the row first, or it was never this caller's. The document is already in
|
||||
// the workbench, so a refused resolve must not present as 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 is the one outcome that 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 () => {
|
||||
// The processor shell has no FileContext, so unlocking would produce a document with nowhere to
|
||||
// go. The row promotes its next offer instead.
|
||||
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 () => {
|
||||
// Storage refused, so nothing would be selected on arrival: the row reports it and stays put.
|
||||
// On the prototype: jsdom's storage object is a proxy, so an own-property spy does not take.
|
||||
@@ -262,6 +490,19 @@ 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());
|
||||
|
||||
@@ -273,8 +514,303 @@ describe("useNotificationActions", () => {
|
||||
// to gate here.
|
||||
expect(
|
||||
registry(inProcessor).VIEW_IN_PROCESSOR?.available(
|
||||
context({ hasLocalFile: false }),
|
||||
context({ hasLocalFile: false, retryPayload: null }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The other retry shape. An attended policy run stashes nothing, so everything these actions need
|
||||
* comes off the row itself: which policy failed, and which document this browser was holding.
|
||||
*/
|
||||
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 () => {
|
||||
// The same document can have failed a tool run earlier, leaving a stash keyed on it. The row
|
||||
// is about the policy, so that is what runs again.
|
||||
await registry().RETRY?.run(
|
||||
policyContext({
|
||||
retryPayload: {
|
||||
operation: "removePassword",
|
||||
endpoint: "/api/v1/security/remove-password",
|
||||
params: {},
|
||||
fileIds: ["f-1"],
|
||||
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 is a source's hash of a path that was never on any device, so there
|
||||
// is nothing here to re-submit. The server disables the owner's actions on these anyway.
|
||||
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 processor shell mounts the bell outside the app's providers, so a run fired from there
|
||||
// would have no workspace to land its output in.
|
||||
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[] = [];
|
||||
addFiles.mockImplementation(async () => {
|
||||
order.push("adopt");
|
||||
return [{ fileId: "f-unlocked" }];
|
||||
});
|
||||
rerunPolicyOnDocument.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");
|
||||
// Added and selected, so the unlocked document is what is on screen once the panel closes. The
|
||||
// encrypted original is left alone: the user never asked to lose it.
|
||||
const [files, options] = addFiles.mock.calls[0];
|
||||
expect((files as File[]).map((file) => file.name)).toEqual(["invoice.pdf"]);
|
||||
// derivedFromTool is what stops the adoption starting a SECOND run of this same policy: the
|
||||
// dispatch effect in usePolicyAutoRun treats a plain upload as work to enforce. A policy run is
|
||||
// a billed automation run, so a double dispatch double-charges and can open a second incident.
|
||||
expect(options).toEqual({ selectFiles: true, derivedFromTool: true });
|
||||
// Re-submitted under the ORIGINAL reference, so a second failure folds onto this same incident
|
||||
// instead of opening a new one about the same document - while the run's output is attributed to
|
||||
// the ADOPTED document, which is the one now in front of the user.
|
||||
expect(rerunPolicyOnDocument).toHaveBeenCalledWith(
|
||||
{ policyId: "pol-1", fileId: "f-1" },
|
||||
expect.any(File),
|
||||
"f-unlocked",
|
||||
);
|
||||
// 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, from here. The other possible source is the adoption, which is silenced by
|
||||
// derivedFromTool above; see the gate's own test in usePolicyAutoRun.chain.test.tsx.
|
||||
expect(rerunPolicyOnDocument).toHaveBeenCalledTimes(1);
|
||||
expect(rerunPolicy).not.toHaveBeenCalled();
|
||||
expect(addFiles.mock.calls[0][1]).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 being filed against
|
||||
// the encrypted original, which would version the wrong document.
|
||||
addFiles.mockResolvedValue([]);
|
||||
rerunPolicyOnDocument.mockResolvedValue({ ok: true, tracked: false });
|
||||
|
||||
await registry().DECRYPT_AND_RETRY?.run(policyContext(), "hunter2");
|
||||
|
||||
expect(rerunPolicyOnDocument).toHaveBeenCalledWith(
|
||||
{ policyId: "pol-1", fileId: "f-1" },
|
||||
expect.any(File),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the row open when the re-run cannot deliver, and says why", async () => {
|
||||
// The run went, but untracked: nothing polls it, so the processed document never reaches the
|
||||
// workbench. The unlocked INPUT is in, which is not what the user was after, so this may not
|
||||
// present as success. Closing the row here would retire a failure that produced nothing and
|
||||
// still billed a run.
|
||||
rerunPolicyOnDocument.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(addFiles).toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says an untracked plain re-run cannot be delivered either", async () => {
|
||||
// Same hole without a password in it: the local cache could not place the policy, so the run is
|
||||
// unpolled and its output is not coming. The reader is told rather than shown a silent success.
|
||||
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(rerunPolicyOnDocument).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 () => {
|
||||
addFiles.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(rerunPolicyOnDocument).not.toHaveBeenCalled();
|
||||
expect(reportNotificationResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the unlock worked but the re-run did not, and leaves the row open", async () => {
|
||||
rerunPolicyOnDocument.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(addFiles).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: the adoption, the re-run, the resolve. The password is
|
||||
// an argument to one call and goes out of scope after it - it is in no payload, no stash and no
|
||||
// id, so nothing here can persist it.
|
||||
const downstream = [
|
||||
...addFiles.mock.calls,
|
||||
...rerunPolicyOnDocument.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] = rerunPolicyOnDocument.mock.calls[0] as [
|
||||
unknown,
|
||||
File,
|
||||
unknown,
|
||||
];
|
||||
expect(await bytesOf(document)).not.toContain("hunter2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,28 @@ 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 {
|
||||
retryWithPassword,
|
||||
unlockLocalDocument,
|
||||
type RetryOutputFile,
|
||||
type RetryPayload,
|
||||
} from "@app/services/notificationRetry";
|
||||
import {
|
||||
rerunPolicy,
|
||||
rerunPolicyOnDocument,
|
||||
type PolicyRerunOutcome,
|
||||
type PolicyRetryTarget,
|
||||
} from "@app/services/notificationPolicyRetry";
|
||||
import { reportNotificationResolved } from "@app/services/notifications";
|
||||
import { isValidToolId } from "@app/types/toolId";
|
||||
import type { FileContextActions } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import {
|
||||
type ClientActionOutcome,
|
||||
@@ -30,8 +46,9 @@ export {
|
||||
};
|
||||
|
||||
/**
|
||||
* What this build can do about a failure notification: open the document it is about, or go to the
|
||||
* recorded failures in the processor.
|
||||
* What this build can do about a failure notification: unlock the document and take the result, run the
|
||||
* failing work again, open the tool that failed, or go to the incident in the processor. "Run it again"
|
||||
* means two different things, so see {@link RetryTarget}.
|
||||
*
|
||||
* THE SHELL PROBLEM. The portal mounts as a sibling of the route that renders `AppProviders` (see
|
||||
* `proprietary/App.tsx`), so in the processor shell there is no FileContext, ToolWorkflowContext or
|
||||
@@ -85,6 +102,82 @@ function goToEditor(path: string): void {
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool whose run failed, when the stashed operation names one this build still has. The stashed
|
||||
* `params` stay in the stash: a tool's parameters live in component state inside `useBaseParameters`,
|
||||
* which has no seam for initial values, and this is the place that would hand them over once it does.
|
||||
*/
|
||||
function toolPathOf(payload: RetryPayload): string {
|
||||
return isValidToolId(payload.operation)
|
||||
? getToolUrlPath(payload.operation)
|
||||
: "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* What a retry would re-run. A union because the two are genuinely different: a tool retry is an
|
||||
* endpoint plus parameters, which exist only in the client that submitted them, and a policy retry is a
|
||||
* stored policy plus a document, which the server named on the notification itself.
|
||||
*/
|
||||
type RetryTarget =
|
||||
| { readonly kind: "tool"; readonly payload: RetryPayload }
|
||||
/** The policy and the document, exactly as the re-run takes them. */
|
||||
| { readonly kind: "policy"; readonly policy: PolicyRetryTarget };
|
||||
|
||||
/**
|
||||
* Which of the two a notification describes, or null when nothing here can re-run it.
|
||||
*
|
||||
* The policy shape wins where it applies, being the more specific claim: the row says which policy
|
||||
* failed on which document, whereas a stash only says which operation this browser last saw fail on it.
|
||||
*
|
||||
* The attended check repeats `isResolvableHere` in `useNotifications` so this function holds on its own
|
||||
* arguments rather than by arrangement with the caller.
|
||||
*/
|
||||
function retryTargetOf(context: NotificationActionContext): RetryTarget | null {
|
||||
const { notification, hasLocalFile, retryPayload } = context;
|
||||
if (!hasLocalFile) return null;
|
||||
|
||||
const attended = (notification.sourceId ?? null) === null;
|
||||
if (attended && notification.policyId && notification.fileId) {
|
||||
return {
|
||||
kind: "policy",
|
||||
policy: { policyId: notification.policyId, fileId: notification.fileId },
|
||||
};
|
||||
}
|
||||
|
||||
return retryPayload ? { kind: "tool", payload: retryPayload } : null;
|
||||
}
|
||||
|
||||
/** The documents a password-carrying call produced, as files the workbench can take. */
|
||||
function asFiles(outputs: RetryOutputFile[]): File[] {
|
||||
return outputs.map(
|
||||
(output) =>
|
||||
new File([output.blob], output.filename, {
|
||||
type: output.blob.type || "application/pdf",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take what the retry produced into the workbench, so the unlocked document is what the user is looking
|
||||
* at once the panel closes. Added and selected rather than replacing the encrypted original: the unlock
|
||||
* is a new document, and deleting their input is not this button's business.
|
||||
*
|
||||
* `derivedFromTool` is load-bearing. A plain upload is what `usePolicyAutoRun`'s dispatch effect watches
|
||||
* for, so without it this adoption would fire the whole upload policy chain by itself: BILLED automation
|
||||
* runs nobody asked for, on a document that is only here because a retry produced it. It is also simply
|
||||
* true, since the document came out of the remove-password tool.
|
||||
*/
|
||||
async function adopt(
|
||||
actions: FileContextActions,
|
||||
files: File[],
|
||||
): Promise<FileId[]> {
|
||||
const adopted = await actions.addFiles(files, {
|
||||
selectFiles: true,
|
||||
derivedFromTool: true,
|
||||
});
|
||||
return adopted.map((file) => file.fileId);
|
||||
}
|
||||
|
||||
export function useNotificationActions(): ClientActionRegistry {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -166,6 +259,195 @@ export function useNotificationActions(): ClientActionRegistry {
|
||||
goToEditor(EDITOR_BASENAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the editor on a specific tool with the document selected, from either shell. Unlike
|
||||
* {@link openDocument} this always goes through the URL, because the editor reads its tool out
|
||||
* of the URL: selecting alone would leave the workbench on whatever view it was on.
|
||||
*/
|
||||
const openToolWithDocument = (
|
||||
fileId: string | null,
|
||||
path: string,
|
||||
): ClientActionOutcome | void => {
|
||||
if (fileId) {
|
||||
if (fileContext) {
|
||||
fileContext.actions.setSelectedFiles([fileId as FileId]);
|
||||
} else if (!stashSelection(fileId)) {
|
||||
// Nothing would be selected on arrival, so say so here rather than navigate to a page
|
||||
// that looks like it worked.
|
||||
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(path);
|
||||
};
|
||||
|
||||
const unavailable = (): ClientActionOutcome => ({
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.retryUnavailable",
|
||||
"This document can no longer be retried from this browser.",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* What a policy re-run amounted to, in the reader's terms. A rejection after the unlock reads
|
||||
* differently from one before it, so the reader is not left thinking their password was wrong.
|
||||
*
|
||||
* An untracked run is reported as a failure on purpose. It did go, but nothing here will collect
|
||||
* what it produces, and the processed document was the point of the retry: presenting that as
|
||||
* success would close the row on a result that is never arriving.
|
||||
*/
|
||||
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.",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this device can re-run what the row describes. A policy re-run also needs the editor's
|
||||
* providers above the bell, not to submit the run but because a run fired from the processor shell
|
||||
* has no mounted workspace 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 with the document selected rather than re-running from here: it failed once, so
|
||||
// the user gets to see the settings first. A stored policy has none to show, so it simply goes.
|
||||
if (target.kind === "tool") {
|
||||
return openToolWithDocument(
|
||||
context.notification.fileId,
|
||||
toolPathOf(target.payload),
|
||||
);
|
||||
}
|
||||
if (!fileContext) return unavailable();
|
||||
return rerunOutcome(await rerunPolicy(target.policy), false);
|
||||
},
|
||||
};
|
||||
|
||||
const decryptAndRetry: ClientActionSpec = {
|
||||
// Only where there is somewhere to put the result: in the processor shell an unlocked document
|
||||
// would have nowhere to go, so the row promotes its next offer instead.
|
||||
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 for a tool, because only it knows what failed and with which parameters; the unlock
|
||||
// endpoint for a policy, since a locked input is fixed the same way whatever was reading it.
|
||||
const outcome =
|
||||
target.kind === "tool"
|
||||
? await retryWithPassword(target.payload, password)
|
||||
: await unlockLocalDocument(target.policy.fileId, password);
|
||||
// A wrong password lands here, carrying the server's own words, which the row shows.
|
||||
if (!outcome.ok) return outcome;
|
||||
|
||||
// It unlocked, so the user must end up holding it. A failed adoption fails the whole action:
|
||||
// claiming success and dropping the result leaves them nothing for the password they typed.
|
||||
const unlocked = asFiles(outcome.files ?? []);
|
||||
let adopted: FileId[] = [];
|
||||
try {
|
||||
adopted = await adopt(fileContext.actions, unlocked);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
message: t(
|
||||
"notifications.adoptFailed",
|
||||
"The document was unlocked but could not be opened here. Try the tool directly.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Back through the run that choked on the locked document, under the ORIGINAL reference, so a
|
||||
// second failure folds onto this same incident. The adopted id goes too, since that is the
|
||||
// document the output belongs to now. After the adoption, so a refused re-run still leaves the
|
||||
// user holding what their password bought them.
|
||||
if (target.kind === "policy") {
|
||||
const document = unlocked[0];
|
||||
const rerun: PolicyRerunOutcome = document
|
||||
? await rerunPolicyOnDocument(
|
||||
target.policy,
|
||||
document,
|
||||
adopted[0] ?? null,
|
||||
)
|
||||
: { ok: false, reason: "missingFile" };
|
||||
// Anything short of a tracked run stops here, untracked included. The unlocked document
|
||||
// being in the workbench is not the result the user asked for: they wanted what the policy
|
||||
// makes of it, and that output has nowhere to land. Closing the row on the input alone
|
||||
// would retire a failure that is still costing them a billed run and still producing
|
||||
// nothing. One mapper decides, so the message and the resolve cannot disagree.
|
||||
const result = rerunOutcome(rerun, true);
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
|
||||
// Nothing else tells the server the retry worked, so without this the bell keeps reporting a
|
||||
// failure the user has fixed. Reached only once the whole retry has landed: the document is in,
|
||||
// and the re-run is being polled by something that will deliver it. Its result is ignored,
|
||||
// since 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,
|
||||
@@ -183,8 +465,10 @@ export function useNotificationActions(): ClientActionRegistry {
|
||||
};
|
||||
|
||||
return {
|
||||
RETRY: retry,
|
||||
DECRYPT_AND_RETRY: decryptAndRetry,
|
||||
VIEW_FILE: viewFile,
|
||||
VIEW_IN_PROCESSOR: viewInProcessor,
|
||||
};
|
||||
}, [canOpenHere, openInWorkbench, navigate, t]);
|
||||
}, [canOpenHere, openInWorkbench, fileContext, navigate, t]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Re-running the policy a notification says failed. The whole point is that nothing was stashed for
|
||||
* it: the policy and the document both come off the row, and the bytes come out of storage under the
|
||||
* same reference the failure was filed against.
|
||||
*/
|
||||
|
||||
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 }
|
||||
>,
|
||||
}));
|
||||
vi.mock("@app/services/policyStorage", () => ({
|
||||
loadPolicies: () => policies.value,
|
||||
}));
|
||||
|
||||
// The REAL run store, because the point of registering is that the auto-run controller finds the run
|
||||
// there and polls it. A mock would assert the call and prove nothing about the record.
|
||||
const { getRun, isDispatched, resetPolicyRuns } =
|
||||
await import("@app/components/policies/policyRunStore");
|
||||
const { rerunPolicy, rerunPolicyOnDocument } =
|
||||
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, not a new one: the server folds a repeat failure onto the same
|
||||
// incident rather than opening a second row about the same document.
|
||||
expect(runStoredPolicy).toHaveBeenCalledWith("pol-1", [document], "f-1");
|
||||
});
|
||||
|
||||
it("records the run so the editor polls it and delivers its output", async () => {
|
||||
// Without this the retry is invisible: nothing polls the run, no output reaches the workspace,
|
||||
// and the row sits open until the run fails again. usePolicyAutoRun drives all of that 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 backend policy belongs to, which is what the import step needs to honour
|
||||
// the policy's output mode 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 policy deleted since, or a cache this browser never built. The run is left to go, since the
|
||||
// server-side effect is real and the submission has already happened, but a run with no category
|
||||
// cannot be imported or chained, so nothing will ever deliver its output here. The caller is told
|
||||
// as much rather than being handed a bare success it would close the failure on.
|
||||
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("rerunPolicyOnDocument", () => {
|
||||
const unlocked = new File(["%PDF-1.7"], "invoice.pdf");
|
||||
|
||||
it("submits bytes the caller already holds, still under the original reference", async () => {
|
||||
await expect(
|
||||
rerunPolicyOnDocument(target, unlocked, "f-unlocked"),
|
||||
).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, deliberately: the server gets the failure's, so a repeat folds onto the same
|
||||
// incident; the run store gets the adopted one, so the output versions the unlocked document the
|
||||
// user is now looking at rather than the encrypted original they still have.
|
||||
await rerunPolicyOnDocument(target, unlocked, "f-unlocked");
|
||||
|
||||
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 came back from the adoption. Recording it against the failure's reference
|
||||
// would version the encrypted original, which is not what the run produced. So it goes
|
||||
// unrecorded, and `tracked` carries that outward: the caller needs it to keep the failure open,
|
||||
// since an unpolled run delivers nothing however well the submission went.
|
||||
await expect(
|
||||
rerunPolicyOnDocument(target, unlocked, null),
|
||||
).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(
|
||||
rerunPolicyOnDocument(target, unlocked, "f-unlocked"),
|
||||
).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(
|
||||
rerunPolicyOnDocument(target, unlocked, "f-unlocked"),
|
||||
).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(
|
||||
rerunPolicyOnDocument(target, unlocked, "f-unlocked"),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "rejected",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { recordRunStart } from "@app/components/policies/policyRunStore";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Re-running the stored policy that a notification says failed.
|
||||
*
|
||||
* Nothing is stashed for this: the row names the policy and the workspace's own reference to the
|
||||
* document, and the bytes are in this browser's storage under that same reference, so the whole retry
|
||||
* is derivable from the row. A tool retry cannot be, which is why `notificationRetry` has a stash.
|
||||
*
|
||||
* Not a reuse of the auto-run controller, which is a hook. This makes the same single call the
|
||||
* auto-run makes and hands it to the same run store, so from there it is like any other run.
|
||||
*/
|
||||
|
||||
/** The document, and the policy to put it back through. Both read straight off the notification. */
|
||||
export interface PolicyRetryTarget {
|
||||
policyId: string;
|
||||
/**
|
||||
* The workspace reference the failing run was filed against. Sent back unchanged so the server folds
|
||||
* a repeat failure onto the same incident.
|
||||
*/
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of a re-run, so the caller can say it in the reader's own language. The wording belongs to
|
||||
* the component layer, which has `t`.
|
||||
*
|
||||
* `ok` alone is not enough to act on. A run that went but could not be recorded has no one polling it, so
|
||||
* its output never reaches this workspace: nothing about the failure is demonstrably fixed, however
|
||||
* cleanly the submission itself went. `tracked` is that difference, and the caller must not close a row
|
||||
* on the strength of `ok` without it.
|
||||
*/
|
||||
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 either.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run on bytes the caller already holds: the just-unlocked document, which is not in storage under
|
||||
* the failing run's reference and never will be.
|
||||
*
|
||||
* @param workspaceFileId the workspace file this run's output belongs to, which is the ADOPTED document
|
||||
* rather than the one the failure named. Two references on purpose: the server gets the failure's,
|
||||
* so a repeat folds onto the same incident, and the run store gets this one, so the output versions
|
||||
* the document now in front of the user. Null when the adoption produced no id, in which case the
|
||||
* run still goes untracked rather than being filed against the wrong document.
|
||||
*/
|
||||
export async function rerunPolicyOnDocument(
|
||||
target: PolicyRetryTarget,
|
||||
document: File,
|
||||
workspaceFileId: string | null,
|
||||
): Promise<PolicyRerunOutcome> {
|
||||
return submit(target, document, workspaceFileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the run, then record it where every other run is recorded. The recording is what makes the retry
|
||||
* visible: `usePolicyAutoRun` polls every run in the store to completion and imports its outputs, and
|
||||
* that follows from the run being in the store with a real category, hence the lookup below.
|
||||
*
|
||||
* Two things can stop the recording without stopping the run: a local cache that cannot place the policy,
|
||||
* and an adoption that produced no workspace id. Neither is worth refusing the retry over, since the
|
||||
* server-side effect is real, but neither is a delivered result either. Both are reported as untracked so
|
||||
* the caller can say so and leave the failure open rather than closing a row whose output is not coming.
|
||||
*/
|
||||
async function submit(
|
||||
target: PolicyRetryTarget,
|
||||
document: File,
|
||||
workspaceFileId: string | null,
|
||||
): Promise<PolicyRerunOutcome> {
|
||||
// Resolved before the run, so a lookup that throws cannot leave a live run unrecorded.
|
||||
const categoryId = categoryForPolicy(target.policyId);
|
||||
const runTarget = resolvePolicyRunTarget();
|
||||
|
||||
let runId: string;
|
||||
try {
|
||||
runId = await runStoredPolicy(target.policyId, [document], target.fileId);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: "rejected", message: rejectionMessage(error) };
|
||||
}
|
||||
|
||||
// Nothing to file it under, or nothing to file it against. The run itself already went, so it is left
|
||||
// to run: refusing it now would only add a wasted submission to an undeliverable one.
|
||||
if (!categoryId || !workspaceFileId) return { ok: true, tracked: false };
|
||||
|
||||
// Marks (category, file) dispatched as it records, same as any other run: the pair has already run
|
||||
// once, and this is that run again rather than a new one to dispatch later.
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The category whose configured policy this is. The non-hook read rather than `usePolicies`, which needs
|
||||
* the app-config and team contexts the bell's shell may not have. Same cache the auto-run's reconcile
|
||||
* writes, so the same answer.
|
||||
*/
|
||||
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