diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionException.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionException.java index 1c899ab529..bd0ed2d001 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionException.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionException.java @@ -1,20 +1,17 @@ package stirling.software.proprietary.failure; +import org.springframework.http.HttpStatus; + import lombok.Getter; -/** - * Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so - * the service stays web-agnostic and the controller owns the mapping. - */ +/** Carries a {@link Reason} rather than an HTTP status, so the service stays web-agnostic. */ @Getter public class FailureActionException extends RuntimeException { public enum Reason { /** - * No such event, it belongs to another team, or the caller's team did not resolve. One - * reason for all three, so the response does not vary with which it was. Unrelated to - * {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused - * action. + * No such event, another team's, or an unresolved team: one reason, so the answer cannot + * vary. */ EVENT_NOT_FOUND, @@ -22,15 +19,13 @@ public class FailureActionException extends RuntimeException { ACTION_NOT_RECOGNISED, /** - * The action exists but this kind does not declare it, so an incoherent pairing (releasing - * a document whose destination is what failed) cannot be dispatched even by hand. - * - *

Unreachable today: both kinds declare both actions, so no request can trip this guard - * until a kind ships with a restricted action set. Declared now because the guard must - * exist before that kind does, not after. + * The action exists but this kind does not offer it, so it cannot be dispatched by hand. */ ACTION_NOT_DECLARED, + /** Offered, but the client is what runs it, so refused rather than half-performed. */ + ACTION_NOT_DISPATCHABLE, + /** The event is already closed, so no further transition is possible. */ ALREADY_CLOSED } @@ -41,9 +36,21 @@ public class FailureActionException extends RuntimeException { this(reason, message, null); } - /** For a refusal that follows from a lower-level failure, so its stack is not dropped. */ public FailureActionException(Reason reason, String message, Throwable cause) { super(message, cause); this.reason = reason; } + + /** + * Lives with the reasons it maps, so every surface that dispatches an action answers alike. A + * closed row is a conflict, not a bad request: it was well-formed and valid a moment earlier. + */ + public static HttpStatus statusOf(Reason reason) { + return switch (reason) { + case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND; + case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED, ACTION_NOT_DISPATCHABLE -> + HttpStatus.BAD_REQUEST; + case ALREADY_CLOSED -> HttpStatus.CONFLICT; + }; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java index dbfcff7fb4..8f60e9f4a0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java @@ -1,11 +1,52 @@ package stirling.software.proprietary.failure; +import lombok.Getter; + /** - * The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how - * the event is shown and touch nothing else, which is what makes them valid for every kind - * including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet. + * The actions a {@link FailureKind} may declare. Client actions are declared here rather than + * invented per client, so the server keeps deciding what a kind offers, in what order and labelled + * how. */ +@Getter public enum FailureActionId { - ACKNOWLEDGE, - DISMISS + + /** + * Kept in the vocabulary for as long as any persisted row is {@code ACKNOWLEDGED}: such rows + * must stay readable and closable whether or not any kind currently offers this. + */ + ACKNOWLEDGE(Execution.SERVER, "Acknowledge"), + + DISMISS(Execution.SERVER, "Dismiss"), + + /** Open the document behind the incident, in whichever client can resolve its id. */ + VIEW_FILE(Execution.CLIENT, "View file"), + + VIEW_IN_PROCESSOR(Execution.CLIENT, "View in processor"); + + /** Dispatch refuses a {@code CLIENT} id, so this is enforced rather than merely documented. */ + public enum Execution { + + /** {@link FailureActionRegistry} requires a {@link FailureAction} bean for these. */ + SERVER, + + /** + * Declared and rendered, never dispatched: the server has neither the file nor the tool. + */ + CLIENT + } + + private final Execution execution; + + /** English fallback, for a client with no translation for the label key. */ + private final String defaultLabel; + + FailureActionId(Execution execution, String defaultLabel) { + this.execution = execution; + this.defaultLabel = defaultLabel; + } + + /** Also whether it can be dispatched. */ + public boolean runsOnServer() { + return execution == Execution.SERVER; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java index 3928e91845..dad1bd5eda 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionRegistry.java @@ -16,6 +16,9 @@ import lombok.extern.slf4j.Slf4j; * Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the * point: because kinds declare action ids as data, one could name an action nobody implements, * which would otherwise show up as a button that 400s rather than as a failed boot. + * + *

Only {@link FailureActionId.Execution#SERVER} ids belong here: a bean for a client action is + * refused, because dispatch could never reach it. */ @Slf4j @Service @@ -25,6 +28,14 @@ public class FailureActionRegistry { public FailureActionRegistry(List actions) { for (FailureAction action : actions) { + if (!action.id().runsOnServer()) { + throw new IllegalStateException( + "Action " + + action.id() + + " is run by the client, so " + + action.getClass().getName() + + " could never be dispatched"); + } FailureAction clash = byId.put(action.id(), action); if (clash != null) { throw new IllegalStateException( @@ -38,10 +49,7 @@ public class FailureActionRegistry { } } - /** - * Fail fast if any kind declares an action with no handler, naming every gap rather than the - * first, so one boot tells you everything that is missing. - */ + /** Names every gap rather than the first, so one boot tells you everything that is missing. */ @PostConstruct void verifyEveryDeclaredActionHasAHandler() { List gaps = @@ -49,6 +57,7 @@ public class FailureActionRegistry { .flatMap( kind -> kind.getActions().stream() + .filter(FailureActionId::runsOnServer) .filter(action -> !byId.containsKey(action)) .map(action -> kind.getId() + " -> " + action)) .toList(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureAudience.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureAudience.java new file mode 100644 index 0000000000..217dfc7975 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureAudience.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.failure; + +/** + * Who an offered action is for, the read scope having already decided they may see the incident. + * The distinction is possession, not seniority: a reviewer cannot reach a document only its owner + * holds. + */ +public enum FailureAudience { + OWNER, + + /** Anyone who triages the team's incidents, whoever hit them. */ + TEAM_REVIEWER, + + ANYONE_WHO_SEES +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java index c9acd0d590..fa2fd93448 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java @@ -1,7 +1,11 @@ package stirling.software.proprietary.failure; -import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE; import static stirling.software.proprietary.failure.FailureActionId.DISMISS; +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.FailureAudience.ANYONE_WHO_SEES; +import static stirling.software.proprietary.failure.FailureAudience.OWNER; +import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER; import java.util.Arrays; import java.util.HashMap; @@ -20,13 +24,8 @@ import lombok.Getter; * The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback * like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs. * - *

Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a - * new kind 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. - * - *

A kind offers an acknowledgement only where there is something to acknowledge doing. - * With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the - * one that clears it. + *

A new kind ships as a registry entry plus copy. Each offer also says who it is for, since one + * incident is read both by whoever hit it and by whoever reviews after them. */ @Getter public enum FailureKind { @@ -37,8 +36,9 @@ public enum FailureKind { FailureScope.FILE, errorCodes("E004"), fallback("This document is password-protected, so the pipeline could not read it."), - offer(ACKNOWLEDGE), - offer(DISMISS, "dismissSkipFile")), + offer(VIEW_FILE, OWNER), + offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER), + offer(DISMISS, ANYONE_WHO_SEES)), UNKNOWN( FailureStage.INTERNAL, @@ -47,7 +47,11 @@ public enum FailureKind { FailureScope.RUN, noErrorCodes(), fallback("This run failed for a reason Stirling does not yet recognise."), - offer(DISMISS)); + // Same order as every other kind: declaration order is display order, so the document + // leads wherever it is offered rather than moving between failures. + offer(VIEW_FILE, OWNER), + offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER), + offer(DISMISS, ANYONE_WHO_SEES)); private static final String KEY_PREFIX = "portal.failures.kind."; private static final String ACTION_KEY_PREFIX = "portal.failures.action."; @@ -95,22 +99,26 @@ public enum FailureKind { } /** - * One action this kind offers, with the key to label it by. One ordered list rather than ids - * plus a parallel map of overrides, which could disagree with each other. + * One ordered list rather than ids plus parallel maps of audiences and labels, 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, String labelKeySuffix) {} + private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {} - /** An action labelled by this kind's own wording, where the generic label reads badly. */ - private static Offer offer(FailureActionId id, String labelKeySuffix) { - return new Offer(id, labelKeySuffix); + /** Declaration order is display order. */ + private static Offer offer(FailureActionId id, FailureAudience audience) { + return new Offer(id, audience, null); } - /** An action labelled by the shared wording for that action. */ - private static Offer offer(FailureActionId id) { - return new Offer(id, null); + /** + * As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording + * where the shared one reads badly. + */ + private static Offer offer( + FailureActionId id, FailureAudience audience, String labelKeySuffix) { + return new Offer(id, audience, labelKeySuffix); } /** @@ -149,6 +157,22 @@ public enum FailureKind { return offers.stream().map(Offer::id).toList(); } + /** + * 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. + */ + public List getOfferedActions() { + return offers.stream() + .map( + offer -> + new OfferedAction( + offer.id(), labelKeyFor(offer.id()), offer.audience())) + .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) {} + /** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */ public boolean declares(FailureActionId action) { return offers.stream().anyMatch(offer -> offer.id() == action); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java index aac1c3364b..24d3558c25 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java @@ -74,8 +74,10 @@ public class FileRunEventController { @Operation( summary = "Apply an action to a recorded failure", description = - "Rejected with 400 if the failure's kind does not declare the action, so an" - + " action that makes no sense for a given failure cannot be applied.") + "Rejected with 400 if the failure's kind does not declare the action, or if the" + + " action is one the client runs rather than the server, so neither an" + + " action that makes no sense for a given failure nor one the server" + + " cannot perform can be applied.") public FileRunEventView act( @PathVariable String eventId, @PathVariable String actionId, @@ -87,7 +89,8 @@ public class FileRunEventController { FileRunEvent updated = service.dispatch(eventId, actionId, inputs); return FileRunEventView.of(updated, service.availableActions(updated)); } catch (FailureActionException e) { - throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e); + throw new ResponseStatusException( + FailureActionException.statusOf(e.getReason()), e.getMessage(), e); } } @@ -147,18 +150,6 @@ public class FileRunEventController { return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList(); } - /** - * A closed row is a conflict rather than a bad request: the request was well-formed and would - * have been valid a moment earlier. - */ - private static HttpStatus statusFor(FailureActionException.Reason reason) { - return switch (reason) { - case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND; - case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST; - case ALREADY_CLOSED -> HttpStatus.CONFLICT; - }; - } - /** Wrapped rather than a bare array so pagination can be added without breaking clients. */ public record FileRunEventsResponse(List events) {} @@ -178,10 +169,13 @@ public class FileRunEventController { } } - /** Inputs an action declared it needs. Empty for both actions that exist today. */ + /** + * Inputs an action declared it needs. Empty for every action the server runs today: the one + * that needs a password is run by the client, which never sends it here. + */ public record ActionRequest(Map inputs) { - Map safeInputs() { + public Map safeInputs() { return inputs == null ? Map.of() : inputs; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java index 1930b36e6a..316dd34925 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java @@ -98,20 +98,18 @@ public interface FileRunEventRepository extends JpaRepositoryRestricted to that owner's own editor rows. File ids are minted by the client, so scoping - * on team alone would let one caller close a colleague's incidents by naming ids. Processor - * rows are excluded outright: nothing was deleted from an editor there. + *

Scoped by the absence of a source rather than by origin: a source-fed run's {@code fileId} + * is a hash no client can name. Narrowed to the owner's own rows, since clients mint the ids. */ @Modifying(clearAutomatically = true) @Transactional @Query( "update FileRunEventEntity e set e.status =" + " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED," - + " e.statusActor = :actor, e.statusAt = :now where e.origin =" - + " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is" - + " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and" - + " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in" - + " :allowedFrom") + + " e.statusActor = :actor, e.statusAt = :now where e.sourceId is null and" + + " ((:teamId is null and e.teamId is null) or e.teamId = :teamId) and" + + " ((:actor is null and e.actor is null) or e.actor = :actor) and e.fileId in" + + " :fileIds and e.status in :allowedFrom") int markFilesRemoved( @Param("teamId") Long teamId, @Param("actor") String actor, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java index fa858b6e22..10f45f46c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java @@ -21,12 +21,22 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority; * team always comes from the authenticated principal, and scoping applies only when login is * enabled so single-user deployments keep working. When the team cannot be resolved the caller * reads nothing; see {@link #readScope()}. + * + *

The read scope decides who sees an incident; {@link #availableActions} decides who may act. */ @Slf4j @Service @RequiredArgsConstructor public class FileRunEventService { + /** Why an offered action came back disabled. Copy lives under {@code portal.failures}. */ + private static final String CLOSED_REASON_KEY = "portal.failures.disabled.closed"; + + private static final String UNATTENDED_REASON_KEY = "portal.failures.disabled.unattended"; + + /** The row never named a document, so unlike the unattended case no client can find one. */ + private static final String DOCUMENTLESS_REASON_KEY = "portal.failures.disabled.noDocument"; + private final FileRunEventStore store; private final FailureActionRegistry actionRegistry; private final PolicyManagementAuthority policyManagementAuthority; @@ -116,36 +126,17 @@ public class FileRunEventService { * Dispatch an action against one event. * * @throws FailureActionException if the event is not the caller's, the action is unknown, the - * event's kind does not declare the action, or the event is already closed + * event's kind does not declare the action, the client is what runs the action, or the + * event is already closed */ public FileRunEvent dispatch(String eventId, String actionId, Map inputs) { // Whoever can see it can close it: a leader for the whole team, everyone else for the // failures they caused. Someone who fixes their own problem should not have to ask a leader // to clear the row. // - // Closing the row is all this covers. Acting on the document behind it, such as supplying a - // password for a retry, would need its own permission, and no such action exists yet. - ReadScope scope = readScope(); - if (!scope.permitted()) { - // Reported as "no such event", the same as an id from another team, so the response - // does - // not depend on whether the id happens to exist. - throw new FailureActionException( - FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId); - } - FileRunEvent event = - store.find(eventId, scope.teamId()) - // 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. - .filter( - found -> - scope.actor() == null - || scope.actor().equals(found.actor())) - .orElseThrow( - () -> - new FailureActionException( - FailureActionException.Reason.EVENT_NOT_FOUND, - "No such event: " + eventId)); + // Audience decides what is offered, not what may be dispatched, so this scope is the whole + // gate. A server action aimed at OWNER alone would need its own guard here. + FileRunEvent event = requireVisible(eventId); FailureActionId resolvedId = parseActionId(actionId); @@ -156,6 +147,12 @@ public class FileRunEventService { FailureActionException.Reason.ACTION_NOT_DECLARED, "Kind " + event.kind().getId() + " does not offer action " + resolvedId); } + // Without this a client could post VIEW_FILE and be answered as though something happened. + if (!resolvedId.runsOnServer()) { + throw new FailureActionException( + FailureActionException.Reason.ACTION_NOT_DISPATCHABLE, + "Action " + resolvedId + " is run by the client, not the server"); + } if (event.status().terminal()) { throw new FailureActionException( FailureActionException.Reason.ALREADY_CLOSED, @@ -174,23 +171,91 @@ public class FileRunEventService { return action.execute(event, inputs == null ? Map.of() : inputs, currentActor()); } + /** "No such event" rather than a refusal, so trying does not confirm a colleague's exists. */ + private FileRunEvent requireVisible(String eventId) { + ReadScope scope = readScope(); + if (!scope.permitted()) { + return notFound(eventId); + } + return store.find(eventId, scope.teamId()) + .filter(found -> scope.actor() == null || scope.actor().equals(found.actor())) + .orElseGet(() -> notFound(eventId)); + } + + private FileRunEvent notFound(String eventId) { + throw new FailureActionException( + FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId); + } + + public Ownership ownershipOf(FileRunEvent event) { + if (event.actor() == null) { + return Ownership.UNOWNED; + } + String caller = currentActor(); + return event.actor().equals(caller) ? Ownership.MINE : Ownership.THEIRS; + } + /** - * Which of an event's declared actions are usable right now. Decided per row, so the client - * never renders a button that would be refused. + * Offers resolved for one caller, so no client renders a button that would be refused. Outside + * their audience is dropped, not disabled: greyed out would read as a permission problem. */ public List availableActions(FileRunEvent event) { + Ownership ownership = ownershipOf(event); + boolean reviewsTeam = reviewsTeam(); boolean closed = event.status().terminal(); - return event.kind().getActions().stream() - .map( - action -> - new AvailableAction( - action, - event.kind().labelKeyFor(action), - !closed, - closed ? "portal.failures.disabled.closed" : null)) + // Login disabled is excluded: its rows are unowned only for want of users, and its one + // operator owns everything they can see. + boolean unattended = enforced() && ownership == Ownership.UNOWNED; + // Answered here, or the client reports "not on this device" about a document the row never + // identified in the first place. + boolean documentless = event.fileId() == null || event.fileId().isBlank(); + return event.kind().getOfferedActions().stream() + .filter(offer -> offeredTo(offer.audience(), ownership, reviewsTeam)) + .map(offer -> availability(offer, closed, unattended, documentless)) .toList(); } + /** Enabled is derived from the reason, so a disabled button always has one to show. */ + private static AvailableAction availability( + FailureKind.OfferedAction offer, + boolean closed, + boolean unattended, + boolean documentless) { + String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless); + return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason); + } + + /** Closed wins over everything, then the owner-only reasons, most specific first. */ + private static String disabledReasonFor( + FailureAudience audience, boolean closed, boolean unattended, boolean documentless) { + if (closed) { + return CLOSED_REASON_KEY; + } + if (audience != FailureAudience.OWNER) { + return null; + } + if (unattended) { + return UNATTENDED_REASON_KEY; + } + return documentless ? DOCUMENTLESS_REASON_KEY : null; + } + + /** An unattended incident has no owner, so its reviewer inherits the owner's actions. */ + private static boolean offeredTo( + FailureAudience audience, Ownership ownership, boolean reviewsTeam) { + return switch (audience) { + case OWNER -> + ownership == Ownership.MINE || (ownership == Ownership.UNOWNED && reviewsTeam); + case TEAM_REVIEWER -> reviewsTeam; + case ANYONE_WHO_SEES -> true; + }; + } + + /** Login disabled has no roles, so its one operator triages everything. */ + private boolean reviewsTeam() { + return !enforced() || policyManagementAuthority.canEditPolicies(); + } + private FailureActionId parseActionId(String actionId) { for (FailureActionId candidate : FailureActionId.values()) { if (candidate.name().equals(actionId)) { @@ -261,7 +326,6 @@ public class FileRunEventService { return applicationProperties.getSecurity().isEnableLogin(); } - /** One action as offered for a specific event, with its resolved availability. */ public record AvailableAction( FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java index 3809fad2c6..b88ba3d48d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java @@ -60,14 +60,24 @@ public record FileRunEventView( event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli()); } - /** One button, as offered for this specific row. */ + /** + * {@code defaultLabel} and {@code execution} let a client render and route an action it was + * never built with. Declaration order is display order. + */ public record ActionView( - String id, String labelKey, boolean enabled, String disabledReasonKey) { + String id, + String labelKey, + String defaultLabel, + FailureActionId.Execution execution, + boolean enabled, + String disabledReasonKey) { - static ActionView of(FileRunEventService.AvailableAction action) { + public static ActionView of(FileRunEventService.AvailableAction action) { return new ActionView( action.id().name(), action.labelKey(), + action.id().getDefaultLabel(), + action.id().getExecution(), action.enabled(), action.disabledReasonKey()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/Ownership.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/Ownership.java new file mode 100644 index 0000000000..65745a61b1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/Ownership.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.failure; + +/** + * Whose incident this is, from the reader's point of view. Derived on read, never persisted: one + * row is {@code MINE} to whoever hit it and {@code THEIRS} to the leader reviewing after them. + */ +public enum Ownership { + MINE, + + /** A colleague's, visible because the caller reviews the team. */ + THEIRS, + + /** + * An unattended run: a folder, bucket or webhook is its only attribution, so there is no owner. + */ + UNOWNED +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java new file mode 100644 index 0000000000..f2bf36a8dc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.notification; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +/** + * Open to any authenticated user, unlike the failure endpoints it draws on: each source scopes its + * own rows. Read-only, because every action a notification offers runs on the client's own device. + */ +@RestController +@RequestMapping("/api/v1/notifications") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Notifications", description = "Things worth telling the caller about") +public class NotificationController { + + /** How many notifications one read returns when the caller does not say: one panelful. */ + private static final int DEFAULT_LIMIT = 20; + + /** The most one read may return however large a limit the caller asks for. */ + private static final int MAX_LIMIT = 100; + + private final NotificationService notifications; + + @GetMapping + @Operation( + summary = "List the caller's notifications", + description = + "Newest first. Derived from the sources that produce them, so there is nothing" + + " to mark read here yet: the client tracks what it has shown.") + public NotificationsResponse list(@RequestParam(required = false) Integer limit) { + int capped = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT); + return new NotificationsResponse(notifications.list(capped)); + } + + /** Wrapped so paging or a total can be added without breaking clients. */ + public record NotificationsResponse(List notifications) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationService.java new file mode 100644 index 0000000000..f7bf3b8530 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationService.java @@ -0,0 +1,53 @@ +package stirling.software.proprietary.notification; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.failure.FileRunEvent; +import stirling.software.proprietary.failure.FileRunEventService; +import stirling.software.proprietary.failure.FileRunEventView; + +/** + * Derived on read rather than stored: one source today, and a table would need a write path, + * retention and a per-user read model first. Each source scopes its own rows, so this cannot widen. + */ +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final FileRunEventService fileRunEvents; + + /** Newest first, and only open failures: one already dealt with is not news. */ + public List list(int limit) { + return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList(); + } + + /** Prefixes the row id on the way out, so it is never sent bare. */ + private NotificationView fromFailure(FileRunEvent event) { + return new NotificationView( + NotificationSource.FAILURE.qualify(event.id()), + NotificationSource.FAILURE, + event.kind().getId(), + event.origin(), + fileRunEvents.ownershipOf(event), + event.severity(), + event.status(), + event.kind().getTitleKey(), + event.kind().getDefaultTitle(), + event.detail(), + event.fileId(), + event.sourceId(), + event.policyId(), + event.occurrences(), + event.createdAt(), + event.lastSeenAt(), + // A disposition such as Dismiss belongs to the review surface, not the bell. + fileRunEvents.availableActions(event).stream() + .filter(action -> !action.id().runsOnServer()) + .map(FileRunEventView.ActionView::of) + .toList()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationSource.java new file mode 100644 index 0000000000..007e51616f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationSource.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.notification; + +import java.util.Locale; + +/** + * Which subsystem produced a notification. Every id is prefixed with it, so a client never holds + * the producing row's own id and cannot reach that source's endpoints by accident. + */ +public enum NotificationSource { + FAILURE; + + private static final char SEPARATOR = ':'; + + public String prefix() { + return name().toLowerCase(Locale.ROOT) + SEPARATOR; + } + + public String qualify(String sourceRowId) { + return prefix() + sourceRowId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationView.java b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationView.java new file mode 100644 index 0000000000..be04153686 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationView.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.notification; + +import java.time.Instant; +import java.util.List; + +import stirling.software.proprietary.failure.FailureOrigin; +import stirling.software.proprietary.failure.FailureSeverity; +import stirling.software.proprietary.failure.FileRunEventStatus; +import stirling.software.proprietary.failure.FileRunEventView; +import stirling.software.proprietary.failure.Ownership; + +/** + * A source's row flattened to what a bell renders. {@code fileId} is an opaque reference, never a + * name, and two id spaces share it: {@code sourceId} tells them apart. + */ +public record NotificationView( + String id, + NotificationSource source, + String kindId, + FailureOrigin origin, + Ownership ownership, + FailureSeverity severity, + FileRunEventStatus status, + String titleKey, + String defaultTitle, + String detail, + String fileId, + String sourceId, + String policyId, + int occurrences, + Instant createdAt, + Instant lastSeenAt, + List actions) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 9b9fca4133..5a5a3018b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -576,7 +576,9 @@ public class PolicyController { + " under 'fileInput', supporting files under 'assets[i].key' /" + " 'assets[i].file' - only for bindings the policy does not already" + " store). Runs regardless of the policy's enabled flag, which only" - + " gates automatic triggering. Returns a run id.") + + " gates automatic triggering. A single-document run may also send its" + + " own opaque 'fileId', which is recorded against any failure so the" + + " caller can resolve it back to that document. Returns a run id.") public ResponseEntity> runStoredPolicy( @PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { @@ -590,7 +592,14 @@ public class PolicyController { HttpStatus.NOT_FOUND, "No policy: " + policyId)); stampPolicyAudit(policy.toDefinition()); PolicyInputs inputs = toInputs(files); - String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId(); + String runId = + policyRunner + .runWith( + policy, + inputs, + PolicyProgressListener.NOOP, + documentReferenceFor(files, inputs)) + .runId(); return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); } @@ -722,6 +731,19 @@ public class PolicyController { return new PolicyInputs(primary, supportingFiles); } + /** + * Only for a single-document run: an incident holds one file reference, so naming one of + * several would attribute the failure to whichever bound first. Counted off resolved inputs, + * not parts. + */ + private static String documentReferenceFor(PolicyRunFiles files, PolicyInputs inputs) { + String fileId = files.getFileId(); + if (fileId == null || fileId.isBlank() || inputs.primary().size() != 1) { + return null; + } + return fileId; + } + private PolicyProgressListener streamListener(SseEmitter emitter) { return new PolicyProgressListener() { @Override diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java index f42e02a07d..fd4a791c8b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java @@ -16,8 +16,8 @@ import lombok.Data; * from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as * a separate typed {@code json} part. * - *

Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code - * assets[i].file} for each supporting asset. + *

Wire form: {@code fileInput} (repeated) for primaries, {@code assets[i].key} / {@code + * assets[i].file} for each supporting asset, and the optional {@code fileId}. */ @Data @Schema(description = "Files for a policy run: primary documents plus keyed supporting assets") @@ -29,4 +29,16 @@ public class PolicyRunFiles { @Valid @Schema(description = "Supporting files, each bound to the asset key its step references") private List assets = new ArrayList<>(); + + /** + * Recorded against any failure of this run, so the client can resolve the row back to its + * document. Opaque by contract, never a name, and only honoured for a single-document run. + */ + @Schema( + description = + "The caller's opaque id for the document being run, echoed onto any failure" + + " recorded for this run so the originating client can resolve it." + + " Ignored unless exactly one primary document is supplied. Never a" + + " filename.") + private String fileId; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 0d75866a6f..f9c0f719ef 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -151,7 +151,8 @@ public class PolicyEngine { * As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, recording which source * fed the run and its opaque reference to the document. The first says where an unattended * failure came from; the second says which document, and is what lets the same document failing - * again fold into one incident. Both null for a user's upload. + * again fold into one incident. With no source {@code fileIdentity} is the client's own + * reference, with one it is that source's hash; this engine only carries it either way. */ public PolicyRunHandle runPolicy( Policy policy, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index b4609f94ea..d159ebf012 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -120,10 +120,17 @@ public class PolicyRunner { * Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources. * The supplied documents are still counted against the virtual {@link EditorSource}, scoped to * the policy's team, so the Sources overview reports the whole team's editor throughput. + * + * @param documentReference the caller's own opaque reference to the single document it runs on, + * or null when it supplied none or several. Passed through untouched. */ public PolicyRunHandle runWith( - Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { - PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener); + Policy policy, + PolicyInputs inputs, + PolicyProgressListener listener, + String documentReference) { + PolicyRunHandle handle = + policyEngine.runPolicy(policy, inputs, listener, null, documentReference); docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size()); return handle; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java new file mode 100644 index 0000000000..7eaeb01eb4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Pins the five enums {@code file_run_events} stores behind CHECK constraints: adding a value is a + * schema change dressed as a Java one, compiling here and failing against a real database. + */ +class CheckConstrainedEnumsTest { + + @Test + @DisplayName("no value has been added to a CHECK-constrained column's enum") + void everyPersistedEnumStillMatchesTheShippedCheckConstraints() { + assertThat(names(FileRunEventStatus.values())) + .containsExactlyInAnyOrder( + "NEW", "ACKNOWLEDGED", "DISMISSED", "RESOLVED", "FILE_REMOVED"); + assertThat(names(FailureOrigin.values())) + .containsExactlyInAnyOrder("TOOL", "POLICY", "PIPELINE"); + assertThat(names(FailureStage.values())) + .containsExactlyInAnyOrder("INPUT", "INTERNAL", "OUTPUT", "BLOCKED", "NEVER_RAN"); + assertThat(names(FailureSeverity.values())) + .containsExactlyInAnyOrder("ERROR", "WARNING", "INFO"); + assertThat(names(FailureScope.values())) + .containsExactlyInAnyOrder("FILE", "RUN", "POLICY", "SOURCE", "SERVER"); + } + + @Test + @DisplayName("the facets added since are derived, not stored") + void nothingAddedToTheModelReachedTheTable() throws Exception { + // Resolved per reader, so a column would hold the wrong answer for all but one person. + List> persisted = + Arrays.stream(FileRunEventEntity.class.getDeclaredFields()) + .filter(field -> !field.isSynthetic()) + .map(java.lang.reflect.Field::getType) + .toList(); + + assertThat(persisted) + .doesNotContain( + FailureAudience.class, + FailureActionId.class, + FailureActionId.Execution.class, + Ownership.class); + // A plain varchar with no CHECK, which is what lets a new kind ship without a migration. + assertThat(FileRunEventEntity.class.getDeclaredField("kindId").getType()) + .isEqualTo(String.class); + } + + private static List names(Enum[] values) { + return Arrays.stream(values).map(Enum::name).toList(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java index a9baf5288f..03aa5c9402 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java @@ -1,6 +1,9 @@ package stirling.software.proprietary.failure; import static org.assertj.core.api.Assertions.assertThat; +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; import java.io.IOException; import java.io.UncheckedIOException; @@ -29,6 +32,13 @@ import stirling.software.common.util.ExceptionUtils; */ class FailureKindTest { + /** In full, so a declaration pairing the right action with the wrong audience cannot pass. */ + private static FailureKind.OfferedAction offered( + FailureActionId id, FailureAudience audience, String labelKeySuffix) { + return new FailureKind.OfferedAction( + id, "portal.failures.action." + labelKeySuffix, audience); + } + @Nested @DisplayName("every kind is well formed") class Invariants { @@ -60,6 +70,27 @@ 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 usable offer is the row's primary, + // so + // two kinds disagreeing would flip the solid button between rows. + List ranking = + List.of( + FailureActionId.VIEW_FILE, + FailureActionId.VIEW_IN_PROCESSOR, + FailureActionId.DISMISS); + + List 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 ids = new HashSet<>(); @@ -88,6 +119,25 @@ class FailureKindTest { } } + @ParameterizedTest + @EnumSource(FailureKind.class) + void everyOfferSaysWhoItIsFor(FailureKind kind) { + // Read per row to decide what a caller is shown, so a null would leak a button. + for (FailureKind.OfferedAction offer : kind.getOfferedActions()) { + assertThat(offer.audience()) + .as("%s offers %s", kind.getId(), offer.id()) + .isNotNull(); + } + } + + @ParameterizedTest + @EnumSource(FailureKind.class) + void offersEachActionAtMostOnce(FailureKind kind) { + // The same action twice would be two buttons with one meaning, and labelKeyFor would + // answer for the first. + assertThat(kind.getActions()).doesNotHaveDuplicates(); + } + @Test void noTwoKindsClaimTheSameErrorCode() { // Computed independently of duplicateErrorCodes(), then checked against it: the boot @@ -182,10 +232,16 @@ class FailureKindTest { class Unknown { @Test - void offersOnlyTheActionThatClearsIt() { - // Nothing here can be fixed, so "seen it" and "clear it" would be the same decision. - // Offering both just asks the reviewer to press two buttons to reach one outcome. - assertThat(FailureKind.UNKNOWN.getActions()).containsExactly(FailureActionId.DISMISS); + void offersItsOwnerTheirDocumentAndTheRunToWhoeverReviews() { + // Nothing here is known to be fixable, so the offers are just the places to look. + assertThat(FailureKind.UNKNOWN.getOfferedActions()) + .containsExactly( + offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"), + offered( + FailureActionId.VIEW_IN_PROCESSOR, + TEAM_REVIEWER, + "viewInProcessor"), + offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss")); } @Test @@ -238,24 +294,49 @@ class FailureKindTest { } @Test - void aKindWithSomethingToFixOffersTheFixAndAWayToSkipIt() { - assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getActions()) - .containsExactly(FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS); + void offersTheDocumentToItsOwnerAndTheRunToItsReviewer() { + // The point of the audiences: only the owner holds the document. + assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions()) + .containsExactly( + offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"), + offered( + FailureActionId.VIEW_IN_PROCESSOR, + TEAM_REVIEWER, + "viewInProcessor"), + offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss")); } @Test - void overriddenLabelWinsOverTheGenericOne() { - String label = - FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DISMISS); - assertThat(label).isEqualTo("portal.failures.action.dismissSkipFile"); - assertThat(label).isNotEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS)); + void noKindOffersAcknowledgeAnyMore() { + // Kept in the vocabulary for rows already ACKNOWLEDGED; offered by nothing, so + // dispatchable by nothing. + for (FailureKind kind : FailureKind.values()) { + assertThat(kind.declares(FailureActionId.ACKNOWLEDGE)) + .as("%s offers ACKNOWLEDGE", kind.getId()) + .isFalse(); + } } @Test - void genericLabelIsUsedWhenAKindDeclaresNoOverride() { + void everyKindLabelsItsActionsWithTheSharedWordingToday() { + // The per-kind override still exists for wording that reads badly in context. + for (FailureKind kind : FailureKind.values()) { + for (FailureActionId action : kind.getActions()) { + assertThat(kind.labelKeyFor(action)) + .isEqualTo(FailureKind.genericLabelKey(action)); + } + } + } + + @Test + void genericLabelIsDerivedFromTheActionId() { assertThat(FailureKind.UNKNOWN.labelKeyFor(FailureActionId.DISMISS)) .isEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS)) .isEqualTo("portal.failures.action.dismiss"); + assertThat( + FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor( + FailureActionId.VIEW_IN_PROCESSOR)) + .isEqualTo("portal.failures.action.viewInProcessor"); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java index 58b8d1b408..3ece59f6af 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java @@ -128,7 +128,9 @@ class FileRunEventControllerTest { } @Test - void carriesActionsAlreadyResolvedForTheRow() { + void carriesActionsAlreadyResolvedForTheRowAndItsReader() { + // A leader reading a colleague's password failure: the unlock is not theirs to do, + // so it is not in the list at all. given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); List actions = @@ -136,10 +138,32 @@ class FileRunEventControllerTest { assertThat(actions) .extracting(FileRunEventView.ActionView::id) - .containsExactlyInAnyOrder("ACKNOWLEDGE", "DISMISS"); + .containsExactly("VIEW_IN_PROCESSOR", "DISMISS"); assertThat(actions).allMatch(FileRunEventView.ActionView::enabled); } + @Test + void carriesEnoughForAClientToRenderAndRouteAnActionItDoesNotKnow() { + // The English fallback, which side runs it, and where the kind wants it: everything a + // build with no copy for a newly shipped action still needs. + given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(controller.list(null, null, null).events().getFirst().actions()) + .allSatisfy( + action -> { + assertThat(action.defaultLabel()).isNotBlank(); + assertThat(action.execution()).isNotNull(); + }) + .filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id())) + .singleElement() + .satisfies( + action -> { + assertThat(action.execution()) + .isEqualTo(FailureActionId.Execution.CLIENT); + assertThat(action.defaultLabel()).isEqualTo("View in processor"); + }); + } + @Test void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() { // Only visible by asking for the closed status: the default queue drops it. @@ -162,14 +186,18 @@ class FileRunEventControllerTest { void filtersByStatusAndByKind() { FileRunEvent locked = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked"); given(FailureKind.UNKNOWN, TEAM, "open"); - controller.act(locked.id(), "ACKNOWLEDGE", null); + controller.act(locked.id(), "DISMISS", null); - assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events()) - .hasSize(1); - assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events()) + assertThat(controller.list(FileRunEventStatus.DISMISSED, null, null).events()) .extracting(FileRunEventView::fileId) .containsExactly("locked"); - // Acknowledged is still open work, so it stays in the default queue. + // A dismissed row is decided, so the default queue holds only the other one. + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("open"); + assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events()) + .extracting(FileRunEventView::fileId) + .isEmpty(); assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty(); } @@ -211,12 +239,31 @@ class FileRunEventControllerTest { void appliesADeclaredActionAndReturnsTheUpdatedRow() { FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); - FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null); + FileRunEventView updated = controller.act(event.id(), "DISMISS", null); - assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED); + assertThat(updated.status()).isEqualTo(FileRunEventStatus.DISMISSED); assertThat(updated.statusActor()).isEqualTo("reviewer@example.com"); } + @Test + void anActionTheClientRunsIsABadRequest() { + // Offered, and still not the server's to perform: the document is in the browser. + FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + + assertThat(statusOf(() -> controller.act(event.id(), "VIEW_FILE", null))) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void anActionNoKindOffersAnyMoreIsABadRequest() { + // ACKNOWLEDGE is still in the vocabulary for the rows that carry it, and still not + // something any kind offers, so posting it is refused rather than applied. + FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + + assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null))) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test void acceptsAnAbsentBodyBecauseTheseActionsNeedNoInput() { FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); @@ -238,7 +285,7 @@ class FileRunEventControllerTest { // 404 rather than 403, so the response does not confirm the row exists. FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1"); - assertThat(statusOf(() -> controller.act(theirs.id(), "ACKNOWLEDGE", null))) + assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null))) .isEqualTo(HttpStatus.NOT_FOUND); } @@ -248,7 +295,7 @@ class FileRunEventControllerTest { FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); controller.act(event.id(), "DISMISS", null); - assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null))) + assertThat(statusOf(() -> controller.act(event.id(), "DISMISS", null))) .isEqualTo(HttpStatus.CONFLICT); } } @@ -276,7 +323,7 @@ class FileRunEventControllerTest { assertThat(locked.actions()) .extracting(FailureKindView.ActionDeclaration::labelKey) - .contains("portal.failures.action.dismissSkipFile"); + .contains("portal.failures.action.viewFile", "portal.failures.action.dismiss"); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java index 375e46f4ad..ae46a50c8a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java @@ -120,13 +120,20 @@ class FileRunEventHttpIntegrationTest { // Epoch millis, not an ISO string: the client renders relative times from a number. assertThat(row.get("lastSeenAt").isNumber()).isTrue(); + // Resolved for this reader: a leader looking at a colleague's password failure is + // offered the run and a way to close the row, not a password they do not have. JsonNode actions = row.get("actions"); assertThat(actions).hasSize(2); - assertThat(actions.get(0).get("id").asString()).isEqualTo("ACKNOWLEDGE"); + assertThat(actions.get(0).get("id").asString()).isEqualTo("VIEW_IN_PROCESSOR"); assertThat(actions.get(0).get("labelKey").asString()) - .isEqualTo("portal.failures.action.acknowledge"); + .isEqualTo("portal.failures.action.viewInProcessor"); + 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("enabled").asBoolean()).isTrue(); assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue(); + assertThat(actions.get(1).get("id").asString()).isEqualTo("DISMISS"); + assertThat(actions.get(1).get("execution").asString()).isEqualTo("SERVER"); } @Test @@ -154,16 +161,17 @@ class FileRunEventHttpIntegrationTest { void coercesQueryParametersAndFiltersOnThem() throws Exception { String locked = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b"); seed(FailureKind.UNKNOWN, TEAM, "open", "a"); - post("/api/v1/file-run-events/" + locked + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}"); + post("/api/v1/file-run-events/" + locked + "/actions/DISMISS", "{\"inputs\":{}}"); - JsonNode acknowledged = - mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body()) + JsonNode dismissed = + mapper.readTree(get("/api/v1/file-run-events?status=DISMISSED").body()) .get("events"); - assertThat(acknowledged).hasSize(1); + assertThat(dismissed).hasSize(1); JsonNode byKind = mapper.readTree( - get("/api/v1/file-run-events?kindId=INPUT_PASSWORD_PROTECTED") + get("/api/v1/file-run-events?status=DISMISSED" + + "&kindId=INPUT_PASSWORD_PROTECTED") .body()) .get("events"); assertThat(byKind).hasSize(1); @@ -269,25 +277,23 @@ class FileRunEventHttpIntegrationTest { String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom"); HttpResponse response = - post( - "/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE", - "{\"inputs\":{}}"); + post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}"); assertThat(response.statusCode()).isEqualTo(200); JsonNode row = mapper.readTree(response.body()); - assertThat(row.get("status").asString()).isEqualTo("ACKNOWLEDGED"); + assertThat(row.get("status").asString()).isEqualTo("DISMISSED"); assertThat(row.get("statusActor").asString()).isEqualTo(ACTOR); } @Test void acceptsAPopulatedInputsMap() throws Exception { - // Nothing consumes inputs yet, but the shape must bind so the first action that needs - // one (a password) does not discover a broken contract. + // No server action consumes inputs, but the shape must still bind rather than 400, so a + // client that posts an empty or stale map is not refused over its body. String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "locked"); HttpResponse response = post( - "/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE", + "/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{\"password\":\"hunter2\"}}"); assertThat(response.statusCode()).isEqualTo(200); @@ -315,15 +321,27 @@ class FileRunEventHttpIntegrationTest { .isEqualTo(400); } + @Test + void mapsAnActionTheClientRunsToBadRequest() throws Exception { + // Declared by the kind, refused here: over the wire, so a client that posts a retry + // gets a refusal rather than a 200 implying the server did something. + String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom"); + + assertThat( + post( + "/api/v1/file-run-events/" + id + "/actions/VIEW_FILE", + "{\"inputs\":{}}") + .statusCode()) + .isEqualTo(400); + } + @Test void mapsAnotherTeamsRowToNotFound() throws Exception { String id = seed(FailureKind.UNKNOWN, 999L, "theirs", "boom"); assertThat( post( - "/api/v1/file-run-events/" - + id - + "/actions/ACKNOWLEDGE", + "/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}") .statusCode()) .isEqualTo(404); @@ -336,9 +354,7 @@ class FileRunEventHttpIntegrationTest { assertThat( post( - "/api/v1/file-run-events/" - + id - + "/actions/ACKNOWLEDGE", + "/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}") .statusCode()) .isEqualTo(409); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java index 4e6508bed9..83ad173637 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.failure; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @@ -60,14 +61,20 @@ class FileRunEventServiceTest { } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { + return givenHitBy("author@example.com", kind, teamId, fileId); + } + + /** As {@link #given} but naming who the incident belongs to, which decides its ownership. */ + private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) { return store.record( new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", - "run-1", + // Distinct per file, so a RUN-scoped kind does not fold two rows into one. + "run-" + fileId, null, fileId, "detail")); @@ -77,11 +84,28 @@ class FileRunEventServiceTest { @DisplayName("acknowledge") class Acknowledge { + /** + * No kind offers it, so it cannot be dispatched; exercised directly for rows that have it. + */ + private FileRunEvent acknowledge(FileRunEvent event, String actor) { + return new AcknowledgeAction(store).execute(event, Map.of(), actor); + } + + @Test + void isNoLongerOfferedSoItCannotBeDispatched() { + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of())) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.ACTION_NOT_DECLARED); + } + @Test void movesANewEventToAcknowledgedAndStampsTheActor() { FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); - FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); + FileRunEvent updated = acknowledge(event, ACTOR); assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED); assertThat(updated.statusActor()).isEqualTo(ACTOR); @@ -91,16 +115,24 @@ class FileRunEventServiceTest { @Test void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() { FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); - FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); - Instant originalAt = first.statusAt(); + Instant originalAt = acknowledge(event, ACTOR).statusAt(); - when(userService.getCurrentUsername()).thenReturn("someone-else@example.com"); - FileRunEvent second = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); + FileRunEvent second = acknowledge(event, "someone-else@example.com"); assertThat(second.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED); assertThat(second.statusActor()).isEqualTo(ACTOR); assertThat(second.statusAt()).isEqualTo(originalAt); } + + @Test + void anAlreadyAcknowledgedRowStaysReadableAndClosable() { + FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + acknowledge(event, ACTOR); + + assertThat(service.list(FileRunEventStatus.ACKNOWLEDGED, null, 10)).hasSize(1); + assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } } @Nested @@ -118,7 +150,7 @@ class FileRunEventServiceTest { @Test void closesAnAcknowledgedEvent() { FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); - service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()); + new AcknowledgeAction(store).execute(event, Map.of(), ACTOR); assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status()) .isEqualTo(FileRunEventStatus.DISMISSED); @@ -180,7 +212,7 @@ class FileRunEventServiceTest { void anotherTeamsEventIsNotFound() { FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1"); - assertThatThrownBy(() -> service.dispatch(theirs.id(), "ACKNOWLEDGE", Map.of())) + assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of())) .isInstanceOf(FailureActionException.class) .extracting(e -> ((FailureActionException) e).getReason()) .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND); @@ -188,12 +220,44 @@ class FileRunEventServiceTest { @Test void anUnknownEventIdIsNotFound() { - assertThatThrownBy(() -> service.dispatch("nope", "ACKNOWLEDGE", Map.of())) + assertThatThrownBy(() -> service.dispatch("nope", "DISMISS", Map.of())) .isInstanceOf(FailureActionException.class) .extracting(e -> ((FailureActionException) e).getReason()) .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND); } + @Test + void anActionTheClientRunsIsRefusedRatherThanPretendedTo() { + // Answering 200 would tell the client something happened when nothing did. + FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); + + assertThatThrownBy(() -> service.dispatch(event.id(), "VIEW_FILE", Map.of())) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.ACTION_NOT_DISPATCHABLE); + + assertThat(store.find(event.id(), TEAM).orElseThrow().status()) + .isEqualTo(FileRunEventStatus.NEW); + } + + @Test + void everyClientActionIsRefusedWhicheverKindDeclaresIt() { + // Over the whole vocabulary, so a client action added later cannot arrive dispatchable. + for (FailureKind kind : FailureKind.values()) { + FileRunEvent event = given(kind, TEAM, "f-" + kind.getId()); + for (FailureActionId action : kind.getActions()) { + if (action.runsOnServer()) { + continue; + } + assertThatThrownBy(() -> service.dispatch(event.id(), action.name(), Map.of())) + .as("%s offers %s", kind.getId(), action) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.ACTION_NOT_DISPATCHABLE); + } + } + } + @Test void anUnknownActionIdIsRejected() { FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); @@ -231,11 +295,6 @@ class FileRunEventServiceTest { FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); service.dispatch(event.id(), "DISMISS", Map.of()); - assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of())) - .isInstanceOf(FailureActionException.class) - .extracting(e -> ((FailureActionException) e).getReason()) - .isEqualTo(FailureActionException.Reason.ALREADY_CLOSED); - assertThatThrownBy(() -> service.dispatch(event.id(), "DISMISS", Map.of())) .isInstanceOf(FailureActionException.class) .extracting(e -> ((FailureActionException) e).getReason()) @@ -244,18 +303,184 @@ class FileRunEventServiceTest { } @Nested - @DisplayName("available actions are resolved per row") - class Availability { + @DisplayName("ownership is derived against whoever is reading") + class OwnershipDerivation { @Test - void openRowOffersEveryDeclaredActionEnabled() { - FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + void theCallersOwnFailureIsMine() { + FileRunEvent mine = givenHitBy(ACTOR, FailureKind.UNKNOWN, TEAM, "f1"); - List actions = service.availableActions(event); + assertThat(service.ownershipOf(mine)).isEqualTo(Ownership.MINE); + } - assertThat(actions).hasSize(2); - assertThat(actions).allMatch(FileRunEventService.AvailableAction::enabled); - assertThat(actions).allMatch(action -> action.disabledReasonKey() == null); + @Test + void aColleaguesIsTheirs() { + FileRunEvent theirs = + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "f1"); + + assertThat(service.ownershipOf(theirs)).isEqualTo(Ownership.THEIRS); + } + + @Test + void anUnattendedRunsIsNobodys() { + // A trigger-fired run has no user to name, so there is nobody to hand the fix to. + FileRunEvent unattended = givenHitBy(null, FailureKind.UNKNOWN, TEAM, "f1"); + + assertThat(service.ownershipOf(unattended)).isEqualTo(Ownership.UNOWNED); + } + + @Test + void theSameRowIsMineToOnePersonAndTheirsToAnother() { + // Why it is derived: a stored answer would be wrong for everyone but one person. + FileRunEvent event = givenHitBy(ACTOR, FailureKind.UNKNOWN, TEAM, "f1"); + assertThat(service.ownershipOf(event)).isEqualTo(Ownership.MINE); + + when(userService.getCurrentUsername()).thenReturn("colleague@example.com"); + + assertThat(service.ownershipOf(event)).isEqualTo(Ownership.THEIRS); + } + } + + @Nested + @DisplayName("available actions are resolved per row and per reader") + class Availability { + + private List offeredFor(FileRunEvent event) { + return service.availableActions(event).stream() + .map(FileRunEventService.AvailableAction::id) + .toList(); + } + + @Test + void theOwnerIsOfferedTheirDocumentAndNotTheReviewersView() { + // The document is theirs to open; the processor view is for whoever reviews the team. + when(authority.canEditPolicies()).thenReturn(false); + FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(offeredFor(mine)) + .containsExactly(FailureActionId.VIEW_FILE, FailureActionId.DISMISS); + assertThat(service.availableActions(mine)) + .allMatch(FileRunEventService.AvailableAction::enabled); + } + + @Test + void aReviewerReadingAColleaguesIsNotOfferedTheDocumentTheyDoNotHave() { + // Dropped, not disabled: greyed out would read as their permission problem. + FileRunEvent theirs = + givenHitBy( + "colleague@example.com", + FailureKind.INPUT_PASSWORD_PROTECTED, + TEAM, + "f1"); + + assertThat(offeredFor(theirs)) + .containsExactly(FailureActionId.VIEW_IN_PROCESSOR, FailureActionId.DISMISS); + } + + @Test + void aReviewerInheritsTheOwnerActionsOnAnUnattendedRow() { + // Nobody owns it, so without the inheritance the row could only ever be dismissed. + FileRunEvent unattended = + givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(offeredFor(unattended)) + .containsExactly( + FailureActionId.VIEW_FILE, + FailureActionId.VIEW_IN_PROCESSOR, + FailureActionId.DISMISS); + } + + @Test + void inheritedOwnerActionsComeBackDisabledWithTheReasonWhy() { + // No browser holds a source-fed file, so it is stated rather than offered as a dead + // button. + FileRunEvent unattended = + givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(service.availableActions(unattended)) + .filteredOn(action -> action.id() != FailureActionId.DISMISS) + .filteredOn(action -> action.id() != FailureActionId.VIEW_IN_PROCESSOR) + .isNotEmpty() + .allSatisfy( + action -> { + assertThat(action.enabled()).isFalse(); + assertThat(action.disabledReasonKey()) + .isEqualTo("portal.failures.disabled.unattended"); + }); + } + + @Test + void theReviewersOwnActionsStayUsableOnAnUnattendedRow() { + FileRunEvent unattended = + givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(service.availableActions(unattended)) + .filteredOn( + action -> + action.id() == FailureActionId.DISMISS + || action.id() == FailureActionId.VIEW_IN_PROCESSOR) + .hasSize(2) + .allMatch(FileRunEventService.AvailableAction::enabled); + } + + @Test + void theOwnersActionsAreDisabledWhenTheRowNamesNoDocument() { + // Answered here, or the client calls it "not on this device" while it sits in their + // own workbench. + FileRunEvent documentless = + givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null); + + assertThat(service.ownershipOf(documentless)).isEqualTo(Ownership.MINE); + assertThat(service.availableActions(documentless)) + .filteredOn(action -> action.id() != FailureActionId.DISMISS) + .filteredOn(action -> action.id() != FailureActionId.VIEW_IN_PROCESSOR) + .isNotEmpty() + .allSatisfy( + action -> { + assertThat(action.enabled()).isFalse(); + assertThat(action.disabledReasonKey()) + .isEqualTo("portal.failures.disabled.noDocument"); + }); + } + + @Test + void aRowThatNamesADocumentKeepsItsOwnerActionsUsable() { + FileRunEvent withDocument = + givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(service.availableActions(withDocument)) + .isNotEmpty() + .allMatch(FileRunEventService.AvailableAction::enabled); + } + + @Test + void aMemberIsNotOfferedTheOwnerActionsOnAnUnattendedRow() { + // The inheritance is the reviewer's: a member has no claim on a run nobody attended. + when(authority.canEditPolicies()).thenReturn(false); + FileRunEvent unattended = + givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); + + assertThat(offeredFor(unattended)).containsExactly(FailureActionId.DISMISS); + } + + @Test + void aLoginDisabledOperatorKeepsTheirOwnActions() { + // Unowned for want of users, not because nothing attended: the one operator holds the + // file. + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().setEnableLogin(false); + FileRunEventService unsecured = + new FileRunEventService( + store, + new FailureActionRegistry(List.of(new DismissAction(store))), + authority, + userService, + props); + FileRunEvent event = givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, null, "f1"); + + assertThat(unsecured.availableActions(event)) + .extracting(FileRunEventService.AvailableAction::enabled) + .containsOnly(true); } @Test @@ -275,21 +500,14 @@ class FileRunEventServiceTest { } @Test - void carriesTheKindsOverriddenLabelWhereItHasOne() { - FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1"); - - assertThat(service.availableActions(event)) - .extracting(FileRunEventService.AvailableAction::labelKey) - .contains("portal.failures.action.dismissSkipFile"); - } - - @Test - void fallsBackToTheGenericLabelWhereTheKindDeclaresNoOverride() { + void carriesTheLabelKeyForEachOffer() { FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1"); assertThat(service.availableActions(event)) .extracting(FileRunEventService.AvailableAction::labelKey) - .containsExactly("portal.failures.action.dismiss"); + .containsExactly( + "portal.failures.action.viewInProcessor", + "portal.failures.action.dismiss"); } } @@ -450,7 +668,43 @@ class FileRunEventServiceTest { complete.verifyEveryDeclaredActionHasAHandler(); for (FailureActionId id : FailureActionId.values()) { - assertThat(complete.find(id)).isPresent(); + // Only server actions need a handler, which is why the boot check ignores the rest. + assertThat(complete.find(id).isPresent()).isEqualTo(id.runsOnServer()); + } + } + + @Test + void doesNotAskForAHandlerForAnActionTheClientRuns() { + // Otherwise every client action would need an empty handler beside it. + FailureActionRegistry serverOnly = + new FailureActionRegistry( + List.of(new AcknowledgeAction(store), new DismissAction(store))); + + assertThatCode(serverOnly::verifyEveryDeclaredActionHasAHandler) + .doesNotThrowAnyException(); + } + + @Test + void refusesAHandlerForAnActionTheClientRuns() { + // Dispatch refuses the id before resolving a handler, so the bean reads as live and is + // not. + assertThatThrownBy(() -> new FailureActionRegistry(List.of(new ClientSideAction()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("VIEW_FILE"); + } + + /** A handler for a client action, which is exactly what must not be registered. */ + private static final class ClientSideAction implements FailureAction { + + @Override + public FailureActionId id() { + return FailureActionId.VIEW_FILE; + } + + @Override + public FileRunEvent execute( + FileRunEvent event, Map inputs, String actor) { + throw new UnsupportedOperationException(); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java index 982d804354..af1353477d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java @@ -233,7 +233,7 @@ class FileRunEventStoreDbTest { } @Test - @DisplayName("closing deleted files touches only that owner's own open editor rows") + @DisplayName("deleting a document closes every incident about it that the deleter caused") void markFilesRemovedIsScopedBySqlNotByTheCaller() { // The scoping is entirely in the JPQL, so the in-memory fake proves nothing about it: // it implements the same rules by hand and would agree with a wrong query. @@ -241,6 +241,10 @@ class FileRunEventStoreDbTest { store.record( RecordFailure.forEditor( FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom")); + // Recorded by the processor, about the document they just deleted. Keying on origin left + // these in the queue. + FileRunEvent myPolicyRun = + store.record(failure(FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1")); FileRunEvent theirs = store.record( RecordFailure.forEditor( @@ -253,21 +257,45 @@ class FileRunEventStoreDbTest { "owner@example.com", "f-1", "boom")); - FileRunEvent fromProcessor = store.record(failure(FailureKind.UNKNOWN, TEAM, "f-1")); int closed = store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1")); - assertThat(closed).isEqualTo(1); + assertThat(closed).isEqualTo(2); assertThat(store.find(mine.id(), TEAM).orElseThrow().status()) .isEqualTo(FileRunEventStatus.FILE_REMOVED); + assertThat(store.find(myPolicyRun.id(), TEAM).orElseThrow().status()) + .as("their upload, their document, now deleted") + .isEqualTo(FileRunEventStatus.FILE_REMOVED); assertThat(store.find(theirs.id(), TEAM).orElseThrow().status()) .as("another person's incident about their own file") .isEqualTo(FileRunEventStatus.NEW); assertThat(store.find(otherTeam.id(), OTHER_TEAM).orElseThrow().status()) .as("another team entirely") .isEqualTo(FileRunEventStatus.NEW); - assertThat(store.find(fromProcessor.id(), TEAM).orElseThrow().status()) - .as("nothing was deleted from an editor here") + } + + @Test + @DisplayName("a source-fed incident survives a client naming its file id") + void markFilesRemovedLeavesSourceFedRowsAlone() { + // With login disabled the actor is null on both sides, so the absence of a source is all + // that stands between a local delete and a sweep's incidents. + FileRunEvent sweep = + store.record( + new RecordFailure( + FailureKind.UNKNOWN, + FailureOrigin.POLICY, + null, + null, + "policy-1", + "run-1", + "src-watched-folder", + "collides-with-a-client-id", + "detail")); + + int closed = store.markFilesRemoved(null, null, List.of("collides-with-a-client-id")); + + assertThat(closed).isZero(); + assertThat(store.find(sweep.id(), null).orElseThrow().status()) .isEqualTo(FileRunEventStatus.NEW); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java index 6dbb16322b..4f429fe9b1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java @@ -134,7 +134,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { Collection allowedFrom) { int closed = 0; for (FileRunEventEntity entity : rows.values()) { - if (entity.getOrigin() != FailureOrigin.TOOL + // Mirrors the real query: scoped by the absence of a source, not by origin. + if (entity.getSourceId() != null || !sameTeam(entity, teamId) || !Objects.equals(entity.getActor(), actor) || entity.getFileId() == null diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationProjectionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationProjectionTest.java new file mode 100644 index 0000000000..e76a8b96ee --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationProjectionTest.java @@ -0,0 +1,168 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.NotificationSource; +import stirling.software.proprietary.notification.NotificationView; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; + +/** + * What the bell is given to render: never a raw event id, and only the actions the client itself + * runs, resolved for this reader by the same service that scopes the queue. + */ +@ExtendWith(MockitoExtension.class) +class NotificationProjectionTest { + + 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")); + } + + @Nested + @DisplayName("the bell holds a prefixed id and nothing else") + class Ids { + + @Test + void everyNotificationIsKeyedByItsSourceAndRowId() { + FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1"); + + NotificationView notification = controller.list(null).notifications().getFirst(); + + assertThat(notification.id()).isEqualTo("failure:" + event.id()); + assertThat(notification.source()).isEqualTo(NotificationSource.FAILURE); + } + } + + @Nested + @DisplayName("what the bell is given to render") + class Projection { + + @Test + void carriesTheKindOriginOwnershipAndTheQueuesClientActions() { + FileRunEvent mine = given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1"); + + NotificationView notification = controller.list(null).notifications().getFirst(); + + assertThat(notification.kindId()).isEqualTo("INPUT_PASSWORD_PROTECTED"); + assertThat(notification.origin()).isEqualTo(FailureOrigin.TOOL); + assertThat(notification.ownership()).isEqualTo(Ownership.MINE); + assertThat(notification.severity()).isEqualTo(FailureSeverity.ERROR); + assertThat(notification.status()).isEqualTo(FileRunEventStatus.NEW); + assertThat(notification.fileId()).isEqualTo("f-1"); + assertThat(notification.policyId()).isNull(); + // How the client knows the fileId above is one of its own and worth looking up. + assertThat(notification.sourceId()).isNull(); + assertThat(notification.defaultTitle()).isNotBlank(); + // The queue's own offers minus the server's: a bell offering different ones would lie. + assertThat(notification.actions()) + .containsExactlyElementsOf( + FileRunEventView.of(mine, failures.availableActions(mine)) + .actions() + .stream() + .filter( + action -> + action.execution() + == FailureActionId.Execution.CLIENT) + .toList()); + } + + @Test + void offersNoActionTheServerRunsBecauseDispositionsBelongToTheQueue() { + // Deciding a failure's fate belongs to the review surface, not the panel. + given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1"); + + assertThat(controller.list(null).notifications().getFirst().actions()) + .isNotEmpty() + .allMatch(action -> action.execution() == FailureActionId.Execution.CLIENT); + } + + @Test + void namesTheSourceThatFedAnUnattendedRunSoItsFileIdIsNotMistakenForAClientsOwn() { + // Without the source a client looks up a hash it can never resolve and calls it + // missing. + store.record( + RecordFailure.forRun( + FailureKind.INPUT_PASSWORD_PROTECTED, + TEAM, + null, + "policy-1", + "run-1", + "source-7", + "hashed-identity", + "boom")); + + NotificationView notification = controller.list(null).notifications().getFirst(); + + assertThat(notification.sourceId()).isEqualTo("source-7"); + assertThat(notification.fileId()).isEqualTo("hashed-identity"); + } + + @Test + void aColleaguesNotificationOffersTheReviewersActionsOnly() { + // A leader sees the team's failures, so audience filtering has to reach the bell too. + given(FailureKind.INPUT_PASSWORD_PROTECTED, "colleague@example.com", "f-1"); + + assertThat(controller.list(null).notifications().getFirst().actions()) + .extracting(FileRunEventView.ActionView::id) + .containsExactly("VIEW_IN_PROCESSOR"); + } + + @Test + void carriesWhatAClientNeedsToRenderAnActionItDoesNotKnow() { + given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1"); + + assertThat(controller.list(null).notifications().getFirst().actions()) + .isNotEmpty() + .allSatisfy( + action -> { + assertThat(action.labelKey()).startsWith("portal.failures.action."); + assertThat(action.defaultLabel()).isNotBlank(); + assertThat(action.execution()).isNotNull(); + }); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureOwnershipTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureOwnershipTest.java new file mode 100644 index 0000000000..0ae6c3a945 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureOwnershipTest.java @@ -0,0 +1,272 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.core.io.ByteArrayResource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.ResourceMonitor; +import stirling.software.common.service.TaskManager; +import stirling.software.common.service.ToolMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.engine.PolicyEngine; +import stirling.software.proprietary.policy.engine.PolicyExecutor; +import stirling.software.proprietary.policy.engine.PolicyRunRegistry; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +import tools.jackson.databind.json.JsonMapper; + +/** + * What a reader is offered on a real recorded row, every collaborator being the real one. Both + * directions are asserted: offered to the wrong reader is either a dead button or a leaked + * document. + */ +@ExtendWith(MockitoExtension.class) +class PolicyFailureOwnershipTest { + + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + private static final Long TEAM = 3L; + + @Mock private InternalApiClient internalApiClient; + @Mock private ToolMetadataService toolMetadataService; + @Mock private TaskManager taskManager; + @Mock private FileStorage fileStorage; + @Mock private JobOwnershipService jobOwnershipService; + @Mock private ResourceMonitor resourceMonitor; + @Mock private JobQueue jobQueue; + @Mock private PolicyStore policyStore; + @Mock private PolicyManagementAuthority authority; + @Mock private UserServiceInterface userService; + + @TempDir Path tempDir; + + private PolicyEngine engine; + private FileRunEventService service; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().setEnableLogin(true); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("failure-ownership-test-"); + + FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository()); + service = + new FileRunEventService( + store, + new FailureActionRegistry( + List.of(new AcknowledgeAction(store), new DismissAction(store))), + authority, + userService, + props); + + PolicyFailureRecorder recorder = + new PolicyFailureRecorder( + new FailureClassifier(JsonMapper.builder().build()), store, policyStore); + PolicyExecutor executor = + new PolicyExecutor( + internalApiClient, + toolMetadataService, + new TempFileManager(new TempFileRegistry(), props), + JsonMapper.builder().build()); + engine = + new PolicyEngine( + executor, + taskManager, + new PolicyRunRegistry(new ApplicationProperties()), + recorder, + fileStorage, + jobOwnershipService, + List.of(new InlineOutputSink(fileStorage)), + new PolicyOutputResolver(new InProcessSourceStore()), + resourceMonitor, + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); + + lenient() + .when(jobOwnershipService.createScopedJobKey(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false); + lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + // The team is resolved from the policy, so the recorded row lands in the reader's team. + lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy())); + lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); + } + + /** Alice's policy, shared with her team. Bob is a member of it and does not own it. */ + private static Policy sharedPolicy() { + return new Policy( + "p1", + "rotate", + "alice", + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline(), + TEAM); + } + + /** Fails the policy's single tool step as {@code triggeredBy} (null = sweep). */ + private void runAndFail(String triggeredBy, String sourceId, String fileIdentity) + throws Exception { + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + if (triggeredBy != null) { + MDC.put("auditPrincipal", triggeredBy); + } + try { + engine.runPolicy( + sharedPolicy(), + PolicyInputs.of(List.of(pdf())), + PolicyProgressListener.NOOP, + sourceId, + fileIdentity) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + } + + private static ByteArrayResource pdf() { + return new ByteArrayResource("input".getBytes()) { + @Override + public String getFilename() { + return "input.pdf"; + } + }; + } + + /** + * Lenient because a leader's scope and an UNOWNED check both answer without asking who reads, + * so whether the name is consulted is the behaviour under test. + */ + private FileRunEvent asMember(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(false); + List visible = service.list(null, null, 10); + return visible.isEmpty() ? null : visible.getFirst(); + } + + /** Read as a team leader, who reviews the whole team's incidents. See {@link #asMember}. */ + private FileRunEvent asReviewer(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(true); + return service.list(null, null, 10).getFirst(); + } + + private List offeredTo(FileRunEvent event) { + return service.availableActions(event).stream() + .map(FileRunEventService.AvailableAction::id) + .toList(); + } + + @Nested + @DisplayName("a non-owner runs a shared policy on their own upload") + class AttendedByANonOwner { + + @Test + void theTriggeringUserHoldsItAndIsOfferedTheDocument() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + FileRunEvent mine = asMember("bob"); + assertThat(service.ownershipOf(mine)).isEqualTo(Ownership.MINE); + assertThat(offeredTo(mine)) + .as("he is holding the document, so opening it is his to do") + .contains(FailureActionId.VIEW_FILE); + assertThat(service.availableActions(mine)) + .filteredOn(action -> action.id() == FailureActionId.VIEW_FILE) + .singleElement() + .satisfies(action -> assertThat(action.enabled()).isTrue()); + } + + @Test + void thePolicyOwnerIsNotHandedADocumentSheNeverTouched() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // She owns the policy and pays for the run, and still has no copy of Bob's file. + FileRunEvent theirs = asReviewer("alice"); + assertThat(service.ownershipOf(theirs)).isEqualTo(Ownership.THEIRS); + assertThat(offeredTo(theirs)).doesNotContain(FailureActionId.VIEW_FILE); + } + + @Test + void theReviewerIsStillOfferedWhatReviewingNeeds() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // Not her document, still her team's incident. + assertThat(offeredTo(asReviewer("alice"))) + .contains(FailureActionId.VIEW_IN_PROCESSOR, FailureActionId.DISMISS); + } + } + + @Nested + @DisplayName("an unattended sweep pulls a file from a source") + class UnattendedSweep { + + @Test + void theRowIsOwnedByNobodySoTheReviewerInheritsTheOwnerActions() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + FileRunEvent unattended = asReviewer("alice"); + assertThat(service.ownershipOf(unattended)).isEqualTo(Ownership.UNOWNED); + // No browser holds this document, so the offer is stated and disabled, not dropped. + assertThat(offeredTo(unattended)).contains(FailureActionId.VIEW_FILE); + assertThat(service.availableActions(unattended)) + .filteredOn(action -> action.id() == FailureActionId.VIEW_FILE) + .singleElement() + .satisfies( + action -> { + assertThat(action.enabled()).isFalse(); + assertThat(action.disabledReasonKey()) + .isEqualTo("portal.failures.disabled.unattended"); + }); + } + + @Test + void thePolicyOwnerDoesNotInheritItAsHerOwn() throws Exception { + // Being billed for the sweep must not become ownership: she gets these as reviewer + // only. + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(service.ownershipOf(asReviewer("alice"))).isNotEqualTo(Ownership.MINE); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java index 73c5add6ad..7472379d36 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java @@ -39,6 +39,7 @@ import tools.jackson.databind.json.JsonMapper; class PolicyFailureRecorderTest { private static final Long TEAM = 11L; + private static final String ACTOR = "dana@example.com"; @Mock private PolicyStore policyStore; @@ -379,5 +380,36 @@ class PolicyFailureRecorderTest { assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2); } + + @Test + void theSameDocumentFailingInTwoAttendedRunsIsOneIncident() { + // Every upload is a new run, so with no reference the run id stands in for the document + // and the same broken file reads as a second incident rather than a second occurrence. + when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); + + recorder.recordRunFailure( + "run-1", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure()); + recorder.recordRunFailure( + "run-2", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure()); + + List events = store.list(TEAM, null, null, null, 10); + assertThat(events).hasSize(1); + assertThat(events.getFirst().occurrences()).isEqualTo(2); + } + + @Test + void twoDocumentsFailingTheSameWayStaySeparateIncidents() { + // Folding is per document, so neither row is credited with the other's occurrence. + when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM))); + + recorder.recordRunFailure( + "run-1", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure()); + recorder.recordRunFailure( + "run-2", "policy-1", null, "editor-file-2", ACTOR, "locked", passwordFailure()); + + assertThat(store.list(TEAM, null, null, null, 10)) + .hasSize(2) + .allMatch(event -> event.occurrences() == 1); + } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java index 86c52c4007..c876644133 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java @@ -11,6 +11,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import stirling.software.proprietary.policy.controller.PolicyRunFiles; + /** * The privacy contract: a recorded failure carries no document identity of its own. There is no * name column and the dedup key is built only from opaque ids, so nothing here derives from what a @@ -53,6 +55,16 @@ class RecordFailurePrivacyTest { .doesNotContain("fileName"); } + @Test + void theRunRequestThatSuppliesADocumentReferenceCarriesNoNameEither() { + // The same discipline at the door as in the row: an id and nothing else, or a document name + // reaches a table that deliberately has nowhere to put it. + assertThat(List.of(PolicyRunFiles.class.getDeclaredFields())) + .extracting(Field::getName) + .contains("fileId") + .doesNotContain("fileName", "documentName", "name"); + } + @Test void dedupKeyIsBuiltOnlyFromOpaqueIdentifiers() { // Two files under the same policy hash differently (so they stay separate incidents), but diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 36f5cc221b..c72a30f98a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -29,6 +29,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -39,6 +40,7 @@ import stirling.software.common.model.job.JobResponse; import stirling.software.common.model.tool.ToolDiagnostic; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; @@ -87,7 +89,10 @@ class PolicyControllerTest { @Mock private ProcessedLedger processedLedger; - @Mock private TempFileManager tempFileManager; + // Real, not mocked: the run endpoints spool uploads through it. + private final TempFileManager tempFileManager = + new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + @Mock private JobOwnershipService jobOwnershipService; private ApplicationProperties applicationProperties; @@ -700,13 +705,46 @@ class PolicyControllerTest { @DisplayName("runStoredPolicy") class RunStoredPolicy { + /** What an editor sends: the documents, plus its own id for a single one of them. */ + private PolicyRunFiles filesWith(String fileId, int documents) { + PolicyRunFiles files = new PolicyRunFiles(); + files.setFileId(fileId); + files.setFileInput( + java.util.stream.IntStream.range(0, documents) + .mapToObj( + i -> + (org.springframework.web.multipart.MultipartFile) + new MockMultipartFile( + "fileInput", + "doc" + i + ".pdf", + "application/pdf", + ("pdf-" + i).getBytes())) + .toList()); + return files; + } + + private String documentReferenceOf(PolicyRunFiles files) throws Exception { + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any())) + .thenReturn(handle("run-9")); + + controller.runStoredPolicy("a", files); + + ArgumentCaptor reference = ArgumentCaptor.forClass(String.class); + verify(policyRunner) + .runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), reference.capture()); + return reference.getValue(); + } + @Test @DisplayName("runs a stored, accessible policy") void runsStored() throws Exception { Policy p = policy("a", 1L); when(policyStore.get("a")).thenReturn(Optional.of(p)); when(policyAccessGuard.canAccess(p)).thenReturn(true); - when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP))) + when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any())) .thenReturn(handle("run-9")); ResponseEntity> response = @@ -716,6 +754,35 @@ class PolicyControllerTest { assertThat(response.getBody().getJobId()).isEqualTo("run-9"); } + @Test + @DisplayName("records the caller's own id for a single-document run") + void carriesTheCallersDocumentReference() throws Exception { + // The point of the field: a failure names a document the client that started it can + // resolve. + assertThat(documentReferenceOf(filesWith("editor-file-1", 1))) + .isEqualTo("editor-file-1"); + } + + @Test + @DisplayName("records nothing when the run carries several documents") + void refusesToGuessWhichOfSeveralDocumentsItIs() throws Exception { + // One incident, one reference: naming one of several would attribute it to whichever + // bound first. + assertThat(documentReferenceOf(filesWith("editor-file-1", 3))).isNull(); + } + + @Test + @DisplayName("records nothing when the caller sent no id") + void toleratesACallerThatSendsNoReference() throws Exception { + assertThat(documentReferenceOf(filesWith(null, 1))).isNull(); + } + + @Test + @DisplayName("records nothing for a blank id") + void treatsABlankReferenceAsNone() throws Exception { + assertThat(documentReferenceOf(filesWith(" ", 1))).isNull(); + } + @Test @DisplayName("not found when the stored policy is inaccessible") void notFound() { @@ -838,7 +905,7 @@ class PolicyControllerTest { Policy p = policy("a", 1L); when(policyStore.get("a")).thenReturn(Optional.of(p)); when(policyAccessGuard.canAccess(p)).thenReturn(true); - when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP))) + when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any())) .thenReturn(handle("run-9")); ResponseEntity> response = diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index d6f5951e3a..ebcf316c25 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -32,6 +32,7 @@ import org.springframework.core.io.ByteArrayResource; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.input.ResolveContext; import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.ledger.IdentityHasher; import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; @@ -293,13 +294,56 @@ class PolicyRunnerTest { Policy policy = policy(List.of(InputSpec.folder("/in"))); PolicyInputs inputs = PolicyInputs.of(List.of()); PolicyRunHandle handle = new PolicyRunHandle("r", new CompletableFuture<>()); - when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP)) + when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP, null, null)) .thenReturn(handle); - assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP)); + assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP, null)); verifyNoInteractions(folderSource); } + @Test + void anAttendedRunCarriesTheClientsOwnDocumentReferenceAndNoSource() { + // A failure of this run can then name the document the user is still holding, and the null + // sourceId is what marks the reference as the client's own rather than a source's hash. + Policy policy = policy(List.of()); + PolicyInputs inputs = PolicyInputs.of(List.of(new ByteArrayResource("a".getBytes()))); + when(policyEngine.runPolicy( + policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1")) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.runWith(policy, inputs, PolicyProgressListener.NOOP, "editor-file-1"); + + verify(policyEngine) + .runPolicy(policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1"); + } + + @Test + void anUnattendedRunStillCarriesItsSourcesHashedIdentity() throws Exception { + // The other id space, unchanged: a folder identity is a path, and a path is a filename, so + // what reaches the run is the one-way hash and never the client-minted kind of reference. + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + String sourceId = policy.inputs().getFirst().sourceId(); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.resolve(eq(spec), any())) + .thenReturn( + List.of( + ResolvedInput.forFile( + PolicyInputs.of(List.of()), "/in/doc.pdf", success -> {}))); + when(policyEngine.runPolicy(any(), any(), any(), any(), any())) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.run(policy); + + verify(policyEngine) + .runPolicy( + eq(policy), + any(), + any(), + eq(sourceId), + eq(IdentityHasher.identityHash("/in/doc.pdf"))); + } + @Test void runWithRecordsSuppliedDocsAgainstTheEditorSourceForThePolicyTeam() { Policy policy = @@ -317,10 +361,11 @@ class PolicyRunnerTest { List.of( new ByteArrayResource("a".getBytes()), new ByteArrayResource("b".getBytes()))); - when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP)) + when(policyEngine.runPolicy( + policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1")) .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); - runner.runWith(policy, inputs, PolicyProgressListener.NOOP); + runner.runWith(policy, inputs, PolicyProgressListener.NOOP, "editor-file-1"); String key = EditorSource.counterKey(7L); assertEquals(2, docCounter.statsFor(List.of(key)).get(key).total()); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1a0f862773..7760f002dd 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5187,6 +5187,30 @@ openProcessor = "Open PDF Processor" count = "{{remaining}} of {{total}}" label = "Free credits" +[notifications] +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." +occurrences = "{{count}} times" +open = "Notifications" +title = "Notifications" +unread = "Unread" + +[notifications.action] +failed = "That did not work. Try again in a moment." +unavailable = "Not available for this notification." + +[notifications.detail] +copied = "Copied" +copy = "Copy error" +less = "Show less" +more = "Show full message" + +[notifications.section] +earlier = "Earlier" +new = "New" + [oauth.error] message = "Authentication was not successful. You can close this window and try again." title = "Authentication Failed" @@ -7506,6 +7530,8 @@ acknowledge = "Acknowledge" confirm = "Are you sure?" dismiss = "Dismiss" dismissSkipFile = "Skip this file" +viewFile = "View file" +viewInProcessor = "View in processor" [portal.failures.debug] copyJson = "Copy JSON" @@ -7517,6 +7543,8 @@ showJson = "Show raw JSON ({{total}})" [portal.failures.disabled] closed = "This failure is already closed." +noDocument = "This failure was not recorded against a specific document, so there is nothing here to open." +unattended = "This file was fed by a folder, bucket or webhook, so nobody's browser is holding it to open." unavailable = "Not available for this failure." [portal.failures.empty] diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 8bc0794140..903c552fd2 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -22,6 +22,7 @@ import WorkbenchFloatingSearch from "@app/components/shared/WorkbenchFloatingSea import LandingPage from "@app/components/shared/LandingPage"; import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton"; import { ChatFAB } from "@app/components/chat/ChatFAB"; +import { NotificationBell } from "@app/components/notifications/NotificationBell"; // Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the // full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file @@ -248,6 +249,15 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > + {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files, + an empty workbench, a custom view without top controls - it gets its own corner, rather + than those being the places a user cannot see that something of theirs failed. */} + {!showWorkbenchBar && ( +

+ +
+ )} + {showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css new file mode 100644 index 0000000000..9352f6bef8 --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -0,0 +1,170 @@ +.notification-bell { + position: relative; + display: inline-flex; +} + +.notification-bell__trigger { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + padding: var(--sp-2, 0.5rem); + border: none; + border-radius: var(--radius-md, 0.375rem); + background: transparent; + color: var(--c-text-muted); + cursor: pointer; +} + +.notification-bell__trigger:hover { + background: var(--c-hover); + color: var(--c-text); +} + +.notification-bell__badge { + position: absolute; + top: 0.125rem; + right: 0.125rem; + min-width: 1rem; + padding: 0 0.25rem; + border-radius: 999px; + background: var(--c-danger); + color: var(--c-text-on-danger); + font-size: 0.625rem; + line-height: 1rem; + text-align: center; +} + +.notification-bell__panel { + position: fixed; + z-index: var(--z-popover, 60); + width: min(22rem, calc(100vw - 2rem)); + max-height: 24rem; + overflow-y: auto; + padding: var(--sp-3, 0.75rem); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg, 0.5rem); + background: var(--c-surface); + box-shadow: 0 10px 30px rgb(0 0 0 / 25%); +} + +.notification-bell__heading { + margin: 0 0 var(--sp-2, 0.5rem); + font-size: 0.875rem; + font-weight: 600; + color: var(--c-text); +} + +.notification-bell__empty { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-muted); +} + +.notification-bell__list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--sp-2, 0.5rem); +} + +.notification-bell__item { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + gap: 0 var(--sp-2, 0.5rem); + padding: var(--sp-2, 0.5rem); + border-radius: var(--radius-md, 0.375rem); + background: var(--c-surface-sunken); +} + +/* Wraps rather than crowds: a row can carry three buttons, and the panel is narrow. */ +.notification-bell__actions { + grid-column: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--sp-1, 0.25rem); + margin-top: var(--sp-2, 0.5rem); +} + +.notification-bell__dot { + grid-row: 1; + align-self: center; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--c-danger); +} + +.notification-bell__item-title { + grid-column: 2; + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.notification-bell__count, +.notification-bell__detail { + grid-column: 2; + font-size: 0.75rem; + color: var(--c-text-muted); +} + +.notification-bell__detail { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow-wrap: anywhere; +} + +/* Expanded, the message is the point of the row, so let it run and scroll rather than clamp. */ +.notification-bell__detail--full { + display: block; + max-height: 10rem; + overflow-y: auto; + -webkit-line-clamp: none; +} + +.notification-bell__chrome { + grid-column: 2; + display: flex; + gap: var(--sp-1, 0.25rem); + margin-top: var(--sp-1, 0.25rem); +} + +/* Reading aids for the message, tinted rather than filled: they sit next to the row's real actions + and must not read as one of them. */ +.notification-bell__chip { + padding: 0.0625rem 0.375rem; + border: none; + border-radius: var(--radius-sm, 0.25rem); + background: var(--c-primary-subtle); + color: var(--c-accent-fg, var(--c-primary)); + font-size: 0.6875rem; + cursor: pointer; +} + +.notification-bell__chip:hover, +.notification-bell__chip:focus-visible { + background: var(--c-hover); +} + +/* Why the actions this row could have had are absent. Muted: it explains, it does not warn. */ +.notification-bell__note { + grid-column: 2; + margin-top: var(--sp-1, 0.25rem); + font-size: 0.75rem; + color: var(--c-text-subtle); +} + +.notification-bell__message { + grid-column: 2; + margin-top: var(--sp-1, 0.25rem); + font-size: 0.75rem; + color: var(--c-danger); +} diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx new file mode 100644 index 0000000000..8753a5880b --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx @@ -0,0 +1,530 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { + AppNotification, + NotificationActionOffer, +} from "@app/services/notifications"; + +// @app/ui Button is a Mantine wrapper, so it needs the provider in the tree. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +/** + * Two things are the bell's own and worth pinning: which notifications the user has already looked + * at, and how a row behaves around an action. + */ + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +// IndexedDB, which jsdom has none of. Answered here so availability is a fact of the test. +const h = vi.hoisted(() => ({ + hasLocalFile: true, + // This build has the notifications API, except in the one test about the build that does not. + notificationsAvailable: true, + specs: {} as Record< + string, + { + available: (context: unknown) => boolean; + run: (context: unknown, password?: string) => unknown; + closesPanel?: boolean; + } + >, +})); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: () => Promise.resolve(h.hasLocalFile), +})); + +vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({ + useNotificationsAvailable: () => h.notificationsAvailable, +})); + +// Core's own registry is empty, so without this there are no client actions to test. +vi.mock("@app/components/notifications/notificationActions", () => ({ + useNotificationActions: () => h.specs, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + // A string fallback, or an options object with defaultValue plus what it interpolates. + t: (key: string, fallback?: unknown) => { + if (typeof fallback === "string") return fallback; + if (fallback && typeof fallback === "object") { + const options = fallback as Record; + const template = options.defaultValue; + if (typeof template !== "string") return key; + return template.replace(/{{(\w+)}}/g, (_match, name: string) => + String(options[name] ?? ""), + ); + } + return key; + }, + }), +})); + +const { NotificationBell } = + await import("@app/components/notifications/NotificationBell"); + +function offer( + id: string, + overrides: Partial = {}, +): NotificationActionOffer { + return { + id, + labelKey: `portal.failures.action.${id.toLowerCase()}`, + defaultLabel: id, + enabled: true, + disabledReasonKey: null, + ...overrides, + }; +} + +function notification( + id: string, + title = "Unrecognised failure", + overrides: Partial = {}, +): AppNotification { + return { + id, + source: "FAILURE", + kindId: "UNKNOWN", + origin: "TOOL", + ownership: "MINE", + severity: "ERROR", + status: "NEW", + titleKey: `portal.failures.kind.${id}.title`, + defaultTitle: title, + detail: "boom", + fileId: "f-1", + sourceId: null, + policyId: null, + occurrences: 1, + createdAt: "2026-08-05T00:00:00Z", + lastSeenAt: "2026-08-05T00:00:00Z", + actions: [], + ...overrides, + }; +} + +async function openPanel() { + fireEvent.click(await screen.findByRole("button")); +} + +describe("NotificationBell", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications.mockReset().mockResolvedValue([]); + h.hasLocalFile = true; + h.notificationsAvailable = true; + h.specs = {}; + }); + + it("mounts nothing at all in a build with no notifications API", async () => { + // No bell and, above all, no poll: an OSS build must not sit on a timer collecting 404s. + h.notificationsAvailable = false; + + render(); + + await Promise.resolve(); + expect(screen.queryByRole("button")).toBeNull(); + expect(fetchNotifications).not.toHaveBeenCalled(); + }); + + it("shows no badge when there is nothing to report", async () => { + render(); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + expect(screen.queryByText("1")).toBeNull(); + }); + + it("counts everything as unread the first time, since nothing has been seen", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + + render(); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("clears the badge once the user opens the panel", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + render(); + await openPanel(); + + // Opening marks them read: waiting for the close would leave the badge lit. + await waitFor(() => expect(screen.queryByText("2")).toBeNull()); + }); + + it("divides what is new from what the user has already seen", async () => { + // "b" was the newest last time, so "a" is the only new one. + window.localStorage.setItem("stirling.notifications.lastSeenId", "b"); + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + render(); + await openPanel(); + + expect(await screen.findByText("New")).toBeTruthy(); + expect(screen.getByText("Earlier")).toBeTruthy(); + }); + + it("keeps the division on screen after opening marks them read", async () => { + // Frozen on open: read live it would collapse the moment the badge cleared. + window.localStorage.setItem("stirling.notifications.lastSeenId", "b"); + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + render(); + await openPanel(); + + await waitFor(() => expect(screen.queryByText("1")).toBeNull()); + expect(screen.getByText("New")).toBeTruthy(); + expect(screen.getByText("Earlier")).toBeTruthy(); + }); + + it("does not divide a list with nothing new in it", async () => { + window.localStorage.setItem("stirling.notifications.lastSeenId", "a"); + fetchNotifications.mockResolvedValue([notification("a")]); + render(); + await openPanel(); + + // A lone "Earlier" heading over everything says nothing the empty badge has not. + expect(await screen.findByText("Unrecognised failure")).toBeTruthy(); + expect(screen.queryByText("Earlier")).toBeNull(); + expect(screen.queryByText("New")).toBeNull(); + }); + + it("labels an all-new list without inventing an earlier section", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + render(); + await openPanel(); + + expect(await screen.findByText("New")).toBeTruthy(); + expect(screen.queryByText("Earlier")).toBeNull(); + }); + + it("marks only what arrived since the user last looked", async () => { + fetchNotifications.mockResolvedValue([notification("a")]); + const first = render(); + await openPanel(); + await waitFor(() => expect(screen.queryByText("1")).toBeNull()); + first.unmount(); + + // A newer one arrives above the one already seen. + fetchNotifications.mockResolvedValue([ + notification("b"), + notification("a"), + ]); + render(); + + expect(await screen.findByText("1")).toBeTruthy(); + }); + + it("treats everything as unread when the last seen one is gone", async () => { + // We cannot tell how far the user got, so show them rather than marking the lot read. + window.localStorage.setItem( + "stirling.notifications.lastSeenId", + "vanished", + ); + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + + render(); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("renders the server's title and repeat count without knowing the source", async () => { + fetchNotifications.mockResolvedValue([ + { ...notification("a", "Password-protected document"), occurrences: 3 }, + ]); + render(); + await openPanel(); + + expect(screen.getByText("Password-protected document")).toBeTruthy(); + expect(screen.getByText("3 times")).toBeTruthy(); + }); + + it("puts every one of the row's actions on the row", async () => { + h.specs = { + VIEW_IN_PROCESSOR: { + available: () => true, + run: vi.fn(), + closesPanel: true, + }, + VIEW_FILE: { available: () => true, run: vi.fn(), closesPanel: true }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + actions: [offer("VIEW_IN_PROCESSOR"), offer("VIEW_FILE")], + }), + ]); + render(); + await openPanel(); + + // Named for their row: every button in the list says the same thing. + for (const id of ["VIEW_IN_PROCESSOR", "VIEW_FILE"]) + expect( + screen.getByRole("button", { name: `${id}: Unrecognised failure` }), + ).toBeTruthy(); + }); + + it("runs whichever of the row's actions is pressed", async () => { + const run = vi.fn(); + h.specs = { + VIEW_IN_PROCESSOR: { available: () => true, run: vi.fn() }, + VIEW_FILE: { available: () => true, run }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + actions: [offer("VIEW_IN_PROCESSOR"), offer("VIEW_FILE")], + }), + ]); + render(); + await openPanel(); + + fireEvent.click( + screen.getByRole("button", { name: "VIEW_FILE: Unrecognised failure" }), + ); + + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(screen.getByText("Unrecognised failure")).toBeTruthy(); + }); + + it("closes the panel on its way to a destination behind it", async () => { + const run = vi.fn(); + h.specs = { VIEW_FILE: { available: () => true, run, closesPanel: true } }; + fetchNotifications.mockResolvedValue([ + notification("a", "Password-protected document", { + actions: [offer("VIEW_FILE")], + }), + ]); + render(); + await openPanel(); + + fireEvent.click( + screen.getByRole("button", { + name: "VIEW_FILE: Password-protected document", + }), + ); + + expect(run).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(screen.queryByText("Password-protected document")).toBeNull(), + ); + }); + + it("skips an action id this build has never heard of", async () => { + // A new failure kind can ship with new actions; an unwired button would be worse than none. + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + actions: [offer("QUARANTINE")], + }), + ]); + render(); + await openPanel(); + + expect(screen.getByText("Unrecognised failure")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /QUARANTINE/ })).toBeNull(); + }); + + it("drops an action the device cannot perform, and says why the row is thin", async () => { + h.hasLocalFile = false; + h.specs = { + VIEW_FILE: { + available: (context) => + (context as { hasLocalFile: boolean }).hasLocalFile, + run: vi.fn(), + }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + actions: [offer("VIEW_FILE")], + }), + ]); + render(); + await openPanel(); + + await waitFor(() => + expect( + screen.getByText( + "This document is not on this device, so it cannot be opened here.", + ), + ).toBeTruthy(), + ); + expect(screen.queryByRole("button", { name: /VIEW_FILE/ })).toBeNull(); + }); + + it("says a row was never linked to a document, rather than that the document is missing", async () => { + h.hasLocalFile = false; + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { fileId: null }), + ]); + render(); + await openPanel(); + + expect( + await screen.findByText( + "This failure is not linked to a specific document, so there is nothing to open here.", + ), + ).toBeTruthy(); + }); + + it("claims nothing about a device for a row it never looks up", async () => { + // Never on any device, so never probed, and an absent lookup is not an absent document. + h.hasLocalFile = false; + fetchNotifications.mockResolvedValue([ + notification("a", "Password-protected document", { + origin: "POLICY", + sourceId: "src-s3-invoices", + }), + ]); + render(); + await openPanel(); + + expect(await screen.findByText("Password-protected document")).toBeTruthy(); + expect( + screen.queryByText( + /not on this device|not linked to a specific document/, + ), + ).toBeNull(); + }); + + it("renders no button for an action the server would refuse, and says why in words", async () => { + // A greyed button that can never work is false hope, so the reason becomes the row's note. + h.specs = { + VIEW_FILE: { available: () => true, run: vi.fn() }, + VIEW_IN_PROCESSOR: { available: () => true, run: vi.fn() }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + ownership: "UNOWNED", + actions: [ + offer("VIEW_FILE", { + enabled: false, + disabledReasonKey: "portal.failures.disabled.unattended", + }), + offer("VIEW_IN_PROCESSOR"), + ], + }), + ]); + render(); + await openPanel(); + + expect(screen.queryByRole("button", { name: /VIEW_FILE/ })).toBeNull(); + expect( + screen.getByRole("button", { + name: "VIEW_IN_PROCESSOR: Unrecognised failure", + }), + ).toBeTruthy(); + expect( + screen.getByText("Not available for this notification."), + ).toBeTruthy(); + }); + + it("leaves a closed row with no buttons rather than a row of dead ones", async () => { + h.specs = { + VIEW_IN_PROCESSOR: { available: () => true, run: vi.fn() }, + VIEW_FILE: { available: () => true, run: vi.fn() }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + actions: [ + offer("VIEW_IN_PROCESSOR", { + enabled: false, + disabledReasonKey: "portal.failures.disabled.closed", + }), + offer("VIEW_FILE", { + enabled: false, + disabledReasonKey: "portal.failures.disabled.closed", + }), + ], + }), + ]); + render(); + await openPanel(); + + // The message and its chips remain, so the row still reads as a row. + expect(screen.getByText("Unrecognised failure")).toBeTruthy(); + expect( + screen.getByText("Not available for this notification."), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /VIEW_IN_PROCESSOR|VIEW_FILE/ }), + ).toBeNull(); + expect(document.querySelector(".notification-bell__actions")).toBeNull(); + }); + + it("shows a failed action in the row instead of leaving the user guessing", async () => { + h.specs = { + VIEW_FILE: { + available: () => true, + run: () => Promise.resolve({ ok: false, message: "Could not open" }), + }, + }; + fetchNotifications.mockResolvedValue([ + notification("a", "Password-protected document", { + actions: [offer("VIEW_FILE")], + }), + ]); + render(); + await openPanel(); + + fireEvent.click( + screen.getByRole("button", { + name: "VIEW_FILE: Password-protected document", + }), + ); + + expect(await screen.findByRole("alert")).toHaveProperty( + "textContent", + "Could not open", + ); + // Still on screen, so the row remains actionable. + expect(screen.getByText("Password-protected document")).toBeTruthy(); + }); + + it("expands the message without touching the row's actions", async () => { + fetchNotifications.mockResolvedValue([ + notification("a", "Unrecognised failure", { + detail: "org.apache.pdfbox.InvalidPasswordException", + }), + ]); + render(); + await openPanel(); + + const expand = screen.getByRole("button", { + name: "Show full message: Unrecognised failure", + }); + fireEvent.click(expand); + + expect( + screen.getByRole("button", { name: "Show less: Unrecognised failure" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Copy error: Unrecognised failure" }), + ).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000000..f2ac1e0a3a --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -0,0 +1,172 @@ +import { + Fragment, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { BellIcon, Button } from "@app/ui"; +import DividerWithText from "@app/components/shared/DividerWithText"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; +import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import "@app/components/notifications/NotificationBell.css"; + +/** + * Renders whatever the server sends without knowing which subsystem produced it or what its actions + * mean, so a new source or failure kind needs no change here. In core because both shells mount it. + */ +export function NotificationBell() { + // A build with no notifications API gets no bell at all, rather than one that polls a + // nonexistent endpoint forever to show nothing. + const available = useNotificationsAvailable(); + if (!available) return null; + return ; +} + +function MountedNotificationBell() { + const { t } = useTranslation(); + const { notifications, unreadCount, documentStateFor, markAllSeen } = + useNotifications(); + const registry = useNotificationActions(); + const [open, setOpen] = useState(false); + const container = useRef(null); + const headingId = useId(); + // Where the new ones stop, frozen when the panel opens (opening marks everything read). + const [firstSeenId, setFirstSeenId] = useState(null); + // Viewport-fixed, because the workbench bar clips its own overflow. + const [anchor, setAnchor] = useState<{ top: number; right: number } | null>( + null, + ); + + useLayoutEffect(() => { + if (!open) return; + const measure = () => { + const rect = container.current?.getBoundingClientRect(); + if (!rect) return; + setAnchor({ + top: rect.bottom + 8, + right: Math.max(8, window.innerWidth - rect.right), + }); + }; + measure(); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + return () => { + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + }; + }, [open]); + + // Opening marks them read, not closing: waiting would leave the badge lit while they read. + const toggle = () => { + setOpen((wasOpen) => { + if (!wasOpen) { + // Before marking, or there is nothing left to read. + setFirstSeenId(notifications[unreadCount]?.id ?? null); + markAllSeen(); + } + return !wasOpen; + }); + }; + + /** + * How many count as new. No boundary id means all of them were; one that has since left the list + * leaves nothing to divide on, so it reads as none rather than guessing at a row. + */ + const boundaryIndex = firstSeenId + ? notifications.findIndex((notification) => notification.id === firstSeenId) + : notifications.length; + const dividedAt = Math.max(0, boundaryIndex); + + useEffect(() => { + if (!open) return; + const closeOnOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!container.current?.contains(target)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + return ( +
+ + + {open && ( +
+

+ {t("notifications.title", "Notifications")} +

+ + {notifications.length === 0 ? ( +

+ {t("notifications.empty", "Nothing to report.")} +

+ ) : ( +
    + {notifications.map((notification, index) => ( + + {index === 0 && dividedAt > 0 && ( +
  • + +
  • + )} + {/* Only with something on both sides: a lone "Earlier" over everything says + nothing the empty badge has not. */} + {index === dividedAt && dividedAt > 0 && ( +
  • + +
  • + )} + setOpen(false)} + /> +
    + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/editor/src/core/components/notifications/NotificationItem.tsx b/frontend/editor/src/core/components/notifications/NotificationItem.tsx new file mode 100644 index 0000000000..b53ca7894c --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationItem.tsx @@ -0,0 +1,251 @@ +import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { isResolvableHere } from "@app/hooks/useNotifications"; +import type { NotificationDocumentState } from "@app/hooks/useNotifications"; +import type { + ClientActionRegistry, + NotificationActionContext, +} from "@app/components/notifications/notificationActions"; +import type { + AppNotification, + NotificationActionOffer, +} from "@app/services/notifications"; + +/** + * The server's reason wins, being about the failure rather than this browser. Otherwise only what we + * actually looked up, so a row we never probed is never called absent. + */ +function noteFor( + notification: AppNotification, + documentState: NotificationDocumentState, + withheldReasonKey: string | null, + t: TFunction, +): string | null { + if (withheldReasonKey) + return t(withheldReasonKey, { + defaultValue: t( + "notifications.action.unavailable", + "Not available for this notification.", + ), + }); + if (notification.ownership !== "MINE" || documentState.hasLocalFile) + return null; + if (!notification.fileId) + return t( + "notifications.noDocumentLinked", + "This failure is not linked to a specific document, so there is nothing to open here.", + ); + return isResolvableHere(notification) + ? t( + "notifications.notOnThisDevice", + "This document is not on this device, so it cannot be opened here.", + ) + : null; +} + +interface NotificationItemProps { + notification: AppNotification; + unread: boolean; + documentState: NotificationDocumentState; + registry: ClientActionRegistry; + onDismissPanel: () => void; +} + +/** Its own component because the last attempt's message and its expanded state are per-row. */ +export function NotificationItem({ + notification, + unread, + documentState, + registry, + onDismissPanel, +}: NotificationItemProps) { + const { t } = useTranslation(); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(null); + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const title = t(notification.titleKey, notification.defaultTitle); + const context: NotificationActionContext = { + notification, + hasLocalFile: documentState.hasLocalFile, + }; + + // 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; + }); + + // Only from an action this build would otherwise have rendered: a reason about one it cannot + // perform anyway is not this row's explanation. + const withheldReasonKey = + notification.actions.find( + (offer) => + !offer.enabled && + offer.disabledReasonKey !== null && + registry[offer.id] !== undefined, + )?.disabledReasonKey ?? null; + + const labelOf = (offer: NotificationActionOffer) => + t(offer.labelKey, offer.defaultLabel); + + const run = async (offer: NotificationActionOffer) => { + if (busy) return; + setMessage(null); + + const spec = registry[offer.id]; + if (!spec) return; + + setBusy(offer.id); + const outcome = await spec.run(context); + setBusy(null); + if (outcome && !outcome.ok) { + setMessage( + outcome.message ?? + t( + "notifications.action.failed", + "That did not work. Try again in a moment.", + ), + ); + return; + } + + if (spec.closesPanel) onDismissPanel(); + }; + + const copyDetail = async () => { + if (!notification.detail) return; + try { + await navigator.clipboard.writeText(notification.detail); + setCopied(true); + } catch { + // No clipboard permission, and the message is on screen and selectable anyway. + } + }; + + const note = noteFor(notification, documentState, withheldReasonKey, t); + + return ( +
  • + {unread && ( + + )} + {title} + {notification.occurrences > 1 && ( + + {t("notifications.occurrences", { + count: notification.occurrences, + defaultValue: "{{count}} times", + })} + + )} + + {notification.detail && ( + <> + + {notification.detail} + + + + + + + )} + + {note && {note}} + + {/* In the kind's declared order, the first leading. */} + {usable.length > 0 && ( + + {usable.map((offer, index) => ( + void run(offer)} + /> + ))} + + )} + + {message && ( + + {message} + + )} +
  • + ); +} + +interface ActionButtonProps { + variant: "primary" | "secondary"; + rowTitle: string; + label: string; + busy: boolean; + onRun: () => void; +} + +function ActionButton({ + variant, + rowTitle, + label, + busy, + onRun, +}: ActionButtonProps) { + return ( + + ); +} diff --git a/frontend/editor/src/core/components/notifications/notificationActions.ts b/frontend/editor/src/core/components/notifications/notificationActions.ts new file mode 100644 index 0000000000..874e6fb824 --- /dev/null +++ b/frontend/editor/src/core/components/notifications/notificationActions.ts @@ -0,0 +1,40 @@ +import type { AppNotification } from "@app/services/notifications"; + +/** + * Keyed by action rather than by row, because the server decides what a kind offers: adding a kind is + * no frontend change, and adding a button is one entry here. + */ + +export interface NotificationActionContext { + notification: AppNotification; + /** Whether the document is still in this browser, which is what most actions hinge on. */ + hasLocalFile: boolean; +} + +/** `void` means it did what it said; a failed outcome carries the message the row shows. */ +export interface ClientActionOutcome { + ok: boolean; + message?: string; +} + +export interface ClientActionSpec { + /** Asked per row, never during a request. */ + available(context: NotificationActionContext): boolean; + run( + context: NotificationActionContext, + ): ClientActionOutcome | void | Promise; + /** Whether the panel should get out of the way, the destination being behind it. */ + closesPanel?: boolean; +} + +/** An id with no entry is skipped rather than rendered unwired. */ +export type ClientActionRegistry = Readonly< + Record +>; + +const NONE: ClientActionRegistry = {}; + +/** Every destination ships in a higher layer, so this build's rows carry no buttons. */ +export function useNotificationActions(): ClientActionRegistry { + return NONE; +} diff --git a/frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts b/frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts new file mode 100644 index 0000000000..1835a0541f --- /dev/null +++ b/frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts @@ -0,0 +1,11 @@ +/** + * Whether this build has a notifications API to read. When it does not, the bell must not + * mount at all: an unconditional mount would poll an endpoint that does not exist, leaving a + * permanent timer and a 404 in the network log for nothing it could ever show. + * + * Core has no failure registry and no notification routes, so the answer here is no; a build + * that ships them overrides this to say so. + */ +export function useNotificationsAvailable(): boolean { + return false; +} diff --git a/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx b/frontend/editor/src/core/components/shared/DividerWithText.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx rename to frontend/editor/src/core/components/shared/DividerWithText.stories.tsx diff --git a/frontend/editor/src/proprietary/components/shared/DividerWithText.tsx b/frontend/editor/src/core/components/shared/DividerWithText.tsx similarity index 100% rename from frontend/editor/src/proprietary/components/shared/DividerWithText.tsx rename to frontend/editor/src/core/components/shared/DividerWithText.tsx diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 19026c9df0..a18b58661f 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -59,6 +59,7 @@ import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbench import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types"; import { useIsMobile } from "@app/hooks/useIsMobile"; import "@app/components/shared/WorkbenchBar.css"; +import { NotificationBell } from "@app/components/notifications/NotificationBell"; const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"]; @@ -602,6 +603,9 @@ export default function WorkbenchBar({ enforcingProgress={enforcingProgress} /> )} + {/* Last in the globals, so it is the rightmost control. */} +
    +
    ); diff --git a/frontend/editor/src/proprietary/components/shared/dividerWithText/DividerWithText.css b/frontend/editor/src/core/components/shared/dividerWithText/DividerWithText.css similarity index 100% rename from frontend/editor/src/proprietary/components/shared/dividerWithText/DividerWithText.css rename to frontend/editor/src/core/components/shared/dividerWithText/DividerWithText.css diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index 634562375c..d9bac4332c 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -611,9 +611,11 @@ function FileContextInner({ // Remove from memory and cleanup resources lifecycleManager.removeFiles(fileIds, stateRef); - // Any failure recorded against these stops needing attention: the document is gone. - // Fire-and-forget, so a server that cannot be told never blocks the delete. - void reportFilesRemoved(fileIds); + // Only a real delete closes a failure: most callers pass false and mean "take it out of the + // workbench", leaving the document, and its failures, very much alive. + if (deleteFromStorage !== false) { + void reportFilesRemoved(fileIds); + } // Remove from IndexedDB if enabled if (indexedDB && enablePersistence && deleteFromStorage !== false) { diff --git a/frontend/editor/src/core/contexts/NavigationContext.tsx b/frontend/editor/src/core/contexts/NavigationContext.tsx index ae1ee43e0d..a7cb95d349 100644 --- a/frontend/editor/src/core/contexts/NavigationContext.tsx +++ b/frontend/editor/src/core/contexts/NavigationContext.tsx @@ -132,7 +132,11 @@ export interface NavigationContextActionsValue { const NavigationStateContext = createContext< NavigationContextStateValue | undefined >(undefined); -const NavigationActionsContext = createContext< +/** + * Exported like {@link FileActionsContext}: a component mounting in both shells must ask whether + * these exist, and {@link useNavigationActions} throws when they do not. + */ +export const NavigationActionsContext = createContext< NavigationContextActionsValue | undefined >(undefined); diff --git a/frontend/editor/src/core/contexts/file/removeFiles.reporting.test.tsx b/frontend/editor/src/core/contexts/file/removeFiles.reporting.test.tsx new file mode 100644 index 0000000000..dec17e25c4 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/removeFiles.reporting.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, act } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import type { FileContextActions } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * `removeFiles` deletes a document or merely takes it out of the workbench, told apart only by + * `deleteFromStorage`. Reporting both closed the user's own notifications as they opened files. + */ + +const reportFilesRemoved = vi.fn(); +vi.mock("@app/services/failureReporting", () => ({ + reportFilesRemoved: (fileIds: string[]) => reportFilesRemoved(fileIds), + reportToolFailure: vi.fn(), +})); + +// IndexedDB, which jsdom has none of. Stubbed so the delete branch can run to the end. +vi.mock("@app/services/fileStorage", () => ({ + // FileContext subscribes to this to drop files whose bytes are unreadable. + onRecordUnreadable: () => () => {}, + fileStorage: { + init: vi.fn().mockResolvedValue(undefined), + deleteMultipleStirlingFiles: vi.fn().mockResolvedValue(undefined), + getAllStirlingFileStubs: vi.fn().mockResolvedValue([]), + }, +})); + +const FILE_ID = "f-1" as FileId; + +let actionsRef: FileContextActions | null = null; + +function Controller() { + actionsRef = useFileActions().actions; + return null; +} + +function setup() { + render( + + + + + , + ); +} + +beforeEach(() => { + reportFilesRemoved.mockReset(); + actionsRef = null; +}); + +describe("removeFiles and the failure queue", () => { + it("tells the server when a document is actually deleted", async () => { + setup(); + + await act(async () => { + await actionsRef?.removeFiles([FILE_ID], true); + }); + + expect(reportFilesRemoved).toHaveBeenCalledWith([FILE_ID]); + }); + + it("says nothing when the file is only closed in the workbench", async () => { + // Closing a tab or unchecking it leaves the document on the device, failures and all. + setup(); + + await act(async () => { + await actionsRef?.removeFiles([FILE_ID], false); + }); + + expect(reportFilesRemoved).not.toHaveBeenCalled(); + }); + + it("treats an unspecified removal as a delete, the way the storage path does", async () => { + // Same default as the IndexedDB branch: only an explicit false means keep. + setup(); + + await act(async () => { + await actionsRef?.removeFiles([FILE_ID]); + }); + + expect(reportFilesRemoved).toHaveBeenCalledWith([FILE_ID]); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index fe1ddc7bc7..822d281c57 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -22,6 +22,7 @@ import { } from "@app/types/fileContext"; import { FILE_EVENTS } from "@app/services/errorUtils"; import { reportToolFailure } from "@app/services/failureReporting"; +import { refreshNotificationsNow } from "@app/hooks/useNotifications"; import { zipFileService } from "@app/services/zipFileService"; import { getFilenameWithoutExtension } from "@app/utils/fileUtils"; import { @@ -606,11 +607,12 @@ export const useToolOperation = ( // Report it so a leader sees the failure too, then carry on with the user's // own error handling. Fire-and-forget: the reporter swallows its own errors. + // Chained, not fired alongside: the re-read must happen after the row exists. void reportToolFailure({ operation: config.operationType, error, fileIds: validFiles.map((file) => file.fileId), - }); + }).then(refreshNotificationsNow); const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); diff --git a/frontend/editor/src/core/hooks/useNotifications.test.ts b/frontend/editor/src/core/hooks/useNotifications.test.ts new file mode 100644 index 0000000000..746db77390 --- /dev/null +++ b/frontend/editor/src/core/hooks/useNotifications.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { AppNotification } from "@app/services/notifications"; + +/** + * The bell is mounted several times over, so what is pinned here is that they share one read: one + * poll, one set of lookups, one marker, and no timer left running once the last has gone. + */ + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +// Counted here so "resolved once per list, not once per row" is observable. +const hasLocalFile = vi.fn((_fileId: string) => Promise.resolve(true)); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: (fileId: string) => hasLocalFile(fileId), +})); + +const { useNotifications, refreshNotificationsNow } = + await import("@app/hooks/useNotifications"); + +function notification( + id: string, + overrides: Partial = {}, +): AppNotification { + return { + id, + source: "FAILURE", + kindId: "UNKNOWN", + origin: "TOOL", + ownership: "MINE", + severity: "ERROR", + status: "NEW", + titleKey: `portal.failures.kind.${id}.title`, + defaultTitle: id, + detail: "boom", + fileId: "f-1", + sourceId: null, + policyId: null, + occurrences: 1, + createdAt: "2026-08-05T00:00:00Z", + lastSeenAt: "2026-08-05T00:00:00Z", + actions: [], + ...overrides, + }; +} + +describe("useNotifications", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications.mockReset().mockResolvedValue([]); + hasLocalFile.mockClear(); + }); + + it("reads the list once however many bells are mounted", async () => { + fetchNotifications.mockResolvedValue([notification("a")]); + + const first = renderHook(() => useNotifications()); + const second = renderHook(() => useNotifications()); + + await waitFor(() => + expect(first.result.current.notifications).toHaveLength(1), + ); + expect(second.result.current.notifications).toHaveLength(1); + expect(fetchNotifications).toHaveBeenCalledTimes(1); + }); + + it("looks a document up once for the list, not once per row", async () => { + fetchNotifications.mockResolvedValue([ + notification("a", { fileId: "f-1" }), + notification("b", { fileId: "f-1" }), + notification("c", { fileId: "f-2" }), + ]); + + const { result } = renderHook(() => useNotifications()); + + await waitFor(() => expect(result.current.notifications).toHaveLength(3)); + expect(hasLocalFile).toHaveBeenCalledTimes(2); + }); + + it("looks up an attended run's document but never an unattended run's", async () => { + // Asking storage about a source's hash can only miss, and would then be shown as "not on this + // device" about a document that never was. + fetchNotifications.mockResolvedValue([ + notification("attended", { + origin: "POLICY", + sourceId: null, + fileId: "editor-file-1", + }), + notification("unattended", { + origin: "POLICY", + sourceId: "src-s3-invoices", + fileId: "hashed-identity", + }), + ]); + + const { result } = renderHook(() => useNotifications()); + + await waitFor(() => expect(result.current.notifications).toHaveLength(2)); + expect(hasLocalFile).toHaveBeenCalledTimes(1); + expect(hasLocalFile).toHaveBeenCalledWith("editor-file-1"); + expect( + result.current.documentStateFor(result.current.notifications[0]) + .hasLocalFile, + ).toBe(true); + expect( + result.current.documentStateFor(result.current.notifications[1]) + .hasLocalFile, + ).toBe(false); + }); + + it("polls on one timer and stops it when the last bell unmounts", async () => { + vi.useFakeTimers(); + try { + const first = renderHook(() => useNotifications()); + const second = renderHook(() => useNotifications()); + await act(async () => {}); + expect(fetchNotifications).toHaveBeenCalledTimes(1); + + // Two bells, one tick: a timer per subscriber would read twice here. + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + expect(fetchNotifications).toHaveBeenCalledTimes(2); + + first.unmount(); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + expect(fetchNotifications).toHaveBeenCalledTimes(3); + + second.unmount(); + await act(async () => { + vi.advanceTimersByTime(120_000); + }); + expect(fetchNotifications).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it("marks every bell read, not just the one the user opened", async () => { + fetchNotifications.mockResolvedValue([ + notification("b"), + notification("a"), + ]); + const first = renderHook(() => useNotifications()); + const second = renderHook(() => useNotifications()); + await waitFor(() => expect(first.result.current.unreadCount).toBe(2)); + expect(second.result.current.unreadCount).toBe(2); + + // Async because subscribers are told on a microtask: a bell marks the list read while rendering. + await act(async () => first.result.current.markAllSeen()); + + expect(first.result.current.unreadCount).toBe(0); + expect(second.result.current.unreadCount).toBe(0); + expect( + window.localStorage.getItem("stirling.notifications.lastSeenId"), + ).toBe("b"); + }); + + it("chains one fresh read behind the read in flight rather than joining it", async () => { + // A refresh exists to observe a write the caller just made. The read in flight may have + // started before that write, so joining it would report the world without it - and the + // caller would wait a whole poll interval for news of their own action. + let release: (listed: AppNotification[]) => void = () => {}; + fetchNotifications.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = renderHook(() => useNotifications()); + expect(fetchNotifications).toHaveBeenCalledTimes(1); + + // A refresh from a row, twice over, and a second bell mounting - all mid-read. The + // refreshes share ONE chained read; the mount joins what is already there. + fetchNotifications.mockResolvedValue([notification("a")]); + act(() => { + first.result.current.refresh(); + first.result.current.refresh(); + }); + const second = renderHook(() => useNotifications()); + expect(fetchNotifications).toHaveBeenCalledTimes(1); + + // The stale read lands empty; the chained fresh read is what delivers the row. + await act(async () => release([])); + await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(first.result.current.notifications).toHaveLength(1), + ); + expect(second.result.current.notifications).toHaveLength(1); + }); + + it("shows a just-reported failure without waiting for the poll", async () => { + const hook = renderHook(() => useNotifications()); + await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(1)); + expect(hook.result.current.unreadCount).toBe(0); + + // The failure report chain: row recorded server-side, then the re-read. + fetchNotifications.mockResolvedValue([notification("a")]); + act(() => refreshNotificationsNow()); + + await waitFor(() => expect(hook.result.current.unreadCount).toBe(1)); + }); + + it("still lands the row when the refresh races a poll read already in flight", async () => { + let releaseStale: (listed: AppNotification[]) => void = () => {}; + fetchNotifications.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseStale = resolve; + }), + ); + + const hook = renderHook(() => useNotifications()); + expect(fetchNotifications).toHaveBeenCalledTimes(1); + + // The failure is recorded while a poll's read is still in flight, then its refresh fires. + // Joining that stale read would miss the row until the next poll interval. + fetchNotifications.mockResolvedValue([notification("a")]); + act(() => refreshNotificationsNow()); + await act(async () => releaseStale([])); + + await waitFor(() => expect(hook.result.current.unreadCount).toBe(1)); + }); + + it("keeps its own list rather than one left by a bell that has gone", async () => { + fetchNotifications.mockResolvedValue([notification("a")]); + const first = renderHook(() => useNotifications()); + await waitFor(() => + expect(first.result.current.notifications).toHaveLength(1), + ); + first.unmount(); + + // It must not show the old row while its own read is in flight. + fetchNotifications.mockResolvedValue([]); + const second = renderHook(() => useNotifications()); + + expect(second.result.current.notifications).toHaveLength(0); + await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(2)); + expect(second.result.current.notifications).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/hooks/useNotifications.ts b/frontend/editor/src/core/hooks/useNotifications.ts new file mode 100644 index 0000000000..9f307a9a42 --- /dev/null +++ b/frontend/editor/src/core/hooks/useNotifications.ts @@ -0,0 +1,240 @@ +import { useSyncExternalStore } from "react"; +import { + fetchNotifications, + type AppNotification, +} from "@app/services/notifications"; +import { hasLocalFile } from "@app/services/localFilePresence"; + +/** + * One polled store for however many bells are mounted. A module store rather than a context because + * the portal mounts its bell as a sibling of AppProviders, so there is no single tree to provide in. + */ + +// TODO: read state is per-browser. Move it server-side when notifications get their own table. +const POLL_INTERVAL_MS = 30_000; +const SEEN_STORAGE_KEY = "stirling.notifications.lastSeenId"; + +function readLastSeenId(): string | null { + try { + return window.localStorage.getItem(SEEN_STORAGE_KEY); + } catch { + // Private mode: everything reads as unseen, which errs towards showing failures. + return null; + } +} + +function writeLastSeenId(id: string): void { + try { + window.localStorage.setItem(SEEN_STORAGE_KEY, id); + } catch { + // The marker just will not survive a reload. + } +} + +export interface NotificationDocumentState { + hasLocalFile: boolean; +} + +const NO_DOCUMENT: NotificationDocumentState = { + hasLocalFile: false, +}; + +/** + * Whether this browser could resolve the document a row names. Two id spaces share `fileId`: an + * attended run reports the id its editor minted, a source-fed one a hash that was never on a device. + */ +export function isResolvableHere(notification: AppNotification): boolean { + return (notification.sourceId ?? null) === null; +} + +interface NotificationsSnapshot { + notifications: AppNotification[]; + /** Keyed by fileId, so several rows about one document cost one lookup. */ + documents: Record; + lastSeenId: string | null; +} + +const NOTHING_LOADED: NotificationsSnapshot = { + notifications: [], + documents: {}, + lastSeenId: null, +}; + +let snapshot: NotificationsSnapshot = NOTHING_LOADED; +const subscribers = new Set<() => void>(); +let pollTimer: number | null = null; +let inFlight: Promise | null = null; +/** Bumped when polling starts or stops, so a read from a finished cycle cannot write. */ +let cycle = 0; +let notifyQueued = false; + +function getSnapshot(): NotificationsSnapshot { + return snapshot; +} + +/** + * Subscribers told on a microtask: a bell marks the list read from inside its own state updater, and + * re-rendering the others from there is the render-phase update React refuses. + */ +function publish(next: NotificationsSnapshot): void { + snapshot = next; + if (notifyQueued) return; + notifyQueued = true; + queueMicrotask(() => { + notifyQueued = false; + subscribers.forEach((notify) => notify()); + }); +} + +async function read(forCycle: number): Promise { + const listed = await fetchNotifications(); + if (forCycle !== cycle) return; + + const fileIds = [ + ...new Set( + listed + .filter(isResolvableHere) + .map((notification) => notification.fileId) + .filter((fileId): fileId is string => fileId !== null), + ), + ]; + const resolved = await Promise.all( + fileIds.map( + async (fileId) => + [ + fileId, + { + hasLocalFile: await hasLocalFile(fileId), + }, + ] as const, + ), + ); + if (forCycle !== cycle) return; + + publish({ + ...snapshot, + notifications: listed, + documents: Object.fromEntries(resolved), + }); +} + +/** A caller arriving mid-read joins the one already running. */ +function load(): Promise { + if (inFlight) return inFlight; + const pending = read(cycle).finally(() => { + if (inFlight === pending) inFlight = null; + }); + inFlight = pending; + return pending; +} + +/** Set while a fresh read is chained behind the one in flight, so callers share it. */ +let freshReadQueued = false; + +/** + * A read that must observe a write the caller just made. It never joins a read already in + * flight, because that read may have started before the write and would report the world + * without it; a fresh read is chained behind it instead. Callers arriving in the same + * window share the one chained read. + */ +function loadFresh(): void { + const inFlightRead = inFlight; + if (!inFlightRead) { + void load(); + return; + } + if (freshReadQueued) return; + freshReadQueued = true; + void inFlightRead.finally(() => { + freshReadQueued = false; + // The last bell may have unmounted while the stale read was landing. + if (subscribers.size === 0) return; + void load(); + }); +} + +function startPolling(): void { + cycle += 1; + // From disk, not memory: another tab may have moved the marker on. + snapshot = { ...NOTHING_LOADED, lastSeenId: readLastSeenId() }; + pollTimer = window.setInterval(() => void load(), POLL_INTERVAL_MS); + void load(); +} + +function stopPolling(): void { + if (pollTimer !== null) { + window.clearInterval(pollTimer); + pollTimer = null; + } + // Drop anything in flight: its cycle has nobody watching it. + cycle += 1; + inFlight = null; + snapshot = NOTHING_LOADED; +} + +/** Polling lives exactly as long as there is a bell to show it. */ +function subscribe(onStoreChange: () => void): () => void { + subscribers.add(onStoreChange); + if (subscribers.size === 1) startPolling(); + return () => { + subscribers.delete(onStoreChange); + if (subscribers.size === 0) stopPolling(); + }; +} + +function markAllSeen(): void { + const newest = snapshot.notifications[0]; + if (!newest || snapshot.lastSeenId === newest.id) return; + writeLastSeenId(newest.id); + publish({ ...snapshot, lastSeenId: newest.id }); +} + +function refresh(): void { + // A row calls this after changing something server-side, so the read must be fresh. + loadFresh(); +} + +/** + * Re-read now, for a caller that just caused a notification: without it the person who triggered a + * failure waits a whole poll interval to hear about their own action. A no-op with no bell mounted. + */ +export function refreshNotificationsNow(): void { + if (subscribers.size === 0) return; + loadFresh(); +} + +export interface NotificationsState { + notifications: AppNotification[]; + /** Read before {@link markAllSeen}, which zeroes it. */ + unreadCount: number; + documentStateFor: ( + notification: AppNotification, + ) => NotificationDocumentState; + markAllSeen: () => void; + refresh: () => void; +} + +export function useNotifications(): NotificationsState { + const { notifications, documents, lastSeenId } = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ); + + // A marker no longer in the list means we cannot tell how far the user got, so everything reads + // as unread rather than being silently marked seen. + const seenIndex = lastSeenId + ? notifications.findIndex((n) => n.id === lastSeenId) + : -1; + const unreadCount = seenIndex === -1 ? notifications.length : seenIndex; + + return { + notifications, + unreadCount, + documentStateFor: (notification) => + (notification.fileId ? documents[notification.fileId] : null) ?? + NO_DOCUMENT, + markAllSeen, + refresh, + }; +} diff --git a/frontend/editor/src/core/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts index 9dfbc5b21b..8545df99e0 100644 --- a/frontend/editor/src/core/i18n/translationAudit.ts +++ b/frontend/editor/src/core/i18n/translationAudit.ts @@ -115,6 +115,9 @@ export const I18N_PROJECTS: TranslationProject[] = [ // invisible to the static scan. The raw catalogue value is the fallback. /^policies\.field\./, /^policyOption\./, + // A failure's disabled reason arrives from the server as a key and is rendered with + // t(thatKey), so nothing in source names it, but the copy still has to exist. + /^portal\.failures\.disabled\./, ], minUsedKeys: 100, minLocaleKeys: 100, diff --git a/frontend/editor/src/core/routes/portalBasename.ts b/frontend/editor/src/core/routes/portalBasename.ts index c9ad3c44a7..a4fdb87a52 100644 --- a/frontend/editor/src/core/routes/portalBasename.ts +++ b/frontend/editor/src/core/routes/portalBasename.ts @@ -5,3 +5,9 @@ * portal (core, desktop, prototypes) must never resolve @portal. */ export const PORTAL_BASENAME = "/processor"; + +/** + * The recorded-failures section of the portal's Documents view. Here because whoever links to it and + * whoever renders it are in different layers. + */ +export const PORTAL_FAILURES_ANCHOR = "failures"; diff --git a/frontend/editor/src/core/services/localFilePresence.test.ts b/frontend/editor/src/core/services/localFilePresence.test.ts new file mode 100644 index 0000000000..1f00d03c0b --- /dev/null +++ b/frontend/editor/src/core/services/localFilePresence.test.ts @@ -0,0 +1,36 @@ +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); + }); +}); diff --git a/frontend/editor/src/core/services/localFilePresence.ts b/frontend/editor/src/core/services/localFilePresence.ts new file mode 100644 index 0000000000..9242c4ca59 --- /dev/null +++ b/frontend/editor/src/core/services/localFilePresence.ts @@ -0,0 +1,18 @@ +import { fileStorage } from "@app/services/fileStorage"; +import type { FileId } from "@app/types/file"; + +/** Whether the document is still in this browser. The id is this workspace's own, so only it can say. */ +export async function hasLocalFile(fileId: string | null): Promise { + 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() !== ""; +} diff --git a/frontend/editor/src/core/services/notifications.ts b/frontend/editor/src/core/services/notifications.ts new file mode 100644 index 0000000000..3101c6056e --- /dev/null +++ b/frontend/editor/src/core/services/notifications.ts @@ -0,0 +1,66 @@ +import apiClient from "@app/services/apiClient"; + +// Derived server-side from whatever produces them, so this client knows nothing about failures. +const NOTIFICATIONS_PATH = "/api/v1/notifications"; + +export type NotificationSource = "FAILURE"; + +export type NotificationSeverity = "ERROR" | "WARNING" | "INFO"; + +export type NotificationOrigin = "TOOL" | "POLICY" | "PIPELINE"; + +/** From this reader's point of view. `UNOWNED` is an unattended run: nobody holds the file. */ +export type NotificationOwnership = "MINE" | "THEIRS" | "UNOWNED"; + +/** `id` is an open string, not a union: the server may know actions this build does not. */ +export interface NotificationActionOffer { + id: string; + labelKey: string; + /** English fallback, for a build with no copy for `labelKey`. */ + defaultLabel: string; + /** False renders no button in the bell, and a disabled one in the portal's queue. */ + enabled: boolean; + disabledReasonKey: string | null; +} + +export interface AppNotification { + /** Prefixed with its source (`failure:`), so it is never an id a per-source endpoint takes. */ + id: string; + source: NotificationSource; + /** Open string, e.g. `INPUT_PASSWORD_PROTECTED`: the server adds kinds without a client change. */ + kindId: string; + origin: NotificationOrigin; + ownership: NotificationOwnership; + severity: NotificationSeverity; + status: string; + titleKey: string; + defaultTitle: string; + detail: string | null; + /** Two id spaces share this field, and `sourceId` says which: see `isResolvableHere`. */ + fileId: string | null; + /** Which folder, bucket or webhook fed the run, and null for an attended one. */ + sourceId: string | null; + policyId: string | null; + occurrences: number; + createdAt: string; + lastSeenAt: string; + actions: NotificationActionOffer[]; +} + +interface NotificationsResponse { + notifications: AppNotification[]; +} + +/** Newest first. Empty rather than throwing: a bell that cannot load is an empty bell, not an error. */ +export async function fetchNotifications( + limit = 20, +): Promise { + try { + const response = await apiClient.get( + `${NOTIFICATIONS_PATH}?limit=${limit}`, + ); + return response?.data?.notifications ?? []; + } catch { + return []; + } +} diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts index d6ebfbe408..fc476af535 100644 --- a/frontend/editor/src/core/tests/helpers/api-stubs.ts +++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts @@ -273,6 +273,12 @@ export async function mockAppApis( await page.route("**/api/v1/policies/runs", (route: Route) => route.fulfill({ json: [] }), ); + + // The bell polls this on load. The hook swallows the failure, but the browser still logs the + // request, which the console-hygiene guard counts. + await page.route("**/api/v1/notifications*", (route: Route) => + route.fulfill({ json: { notifications: [] } }), + ); } /** diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index dd3c2aec7c..8e416af124 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -38,6 +38,7 @@ html[data-app-theme="light"] { --c-success: var(--p-green-600); --c-danger: var(--p-red-600); + --c-text-on-danger: var(--p-white); --c-warning: var(--p-amber-600); /* Solid fills that carry a white label. Deeper than the --c- values above, which are picked for surfaces, borders and icons where the 3:1 @@ -169,6 +170,7 @@ html[data-app-theme="midnight"] { --c-success: var(--p-green-500); --c-danger: var(--p-red-500); + --c-text-on-danger: var(--p-white); --c-warning: var(--p-amber-500); /* Themed decorative dark overrides (see :root for light + rationale). */ @@ -371,6 +373,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { /* Dark-tuned status shades (lighter than the light-theme :root values). */ --c-success: var(--p-green-500); --c-danger: var(--p-red-400); + --c-text-on-danger: var(--p-white); --c-warning: var(--p-amber-500); /* Themed decorative dark overrides (see :root for light + rationale). */ diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index c482e8e54a..b4a48e3d00 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -337,6 +337,11 @@ export interface FileContextActions { insertAfterPageId?: string; selectFiles?: boolean; skipUploadTracking?: boolean; + /** + * Produced in-app rather than uploaded, which stops the policy auto-run enforcing an upload + * policy on it. Set by anything adding a file already through a policy or a tool. + */ + derivedFromTool?: boolean; }, ) => Promise; addFilesWithOptions: ( diff --git a/frontend/editor/src/core/ui/BellIcon.tsx b/frontend/editor/src/core/ui/BellIcon.tsx new file mode 100644 index 0000000000..87908c4af6 --- /dev/null +++ b/frontend/editor/src/core/ui/BellIcon.tsx @@ -0,0 +1,22 @@ +/** + * An outline bell. The bundled Material Symbols set only carries the filled variant, + * which reads as permanently ringing. + */ +export function BellIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index dcf0e9265f..c621319f0e 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -1,5 +1,6 @@ export * from "@app/ui/Button"; export * from "@app/ui/ActionIcon"; +export * from "@app/ui/BellIcon"; export * from "@app/ui/Logo"; export * from "@app/ui/FilePicker"; export * from "@app/ui/SegmentedControl"; diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index d3f1332969..ffbfc2fb21 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -14,7 +14,9 @@ color: var(--c-text-muted); } +/* Anchors .portal-shell__notifications without disturbing the column's flow. */ .portal-shell__main { + position: relative; flex: 1 1 auto; display: flex; flex-direction: column; @@ -69,3 +71,12 @@ animation: fadeIn var(--motion-fast) both; } } + +/* Sits over the main area's top-right corner. Outside .portal-shell__view so it stays put while + the view scrolls, and inside .portal-shell__main so it never overlaps the sidebar. */ +.portal-shell__notifications { + position: absolute; + top: var(--sp-3, 0.75rem); + right: var(--sp-4, 1rem); + z-index: var(--z-sticky, 30); +} diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index 1811fa02c4..fe8f775fd0 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -8,6 +8,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; import { Logo } from "@app/ui/Logo"; import "@portal/components/AppShell.css"; +import { NotificationBell } from "@app/components/notifications/NotificationBell"; /** * Compact header shown only under the mobile breakpoint (CSS-hidden on @@ -89,6 +90,9 @@ export function AppShell({ children }: { children: ReactNode }) {
    +
    + +
    {children}
    diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx index 9ec3af6842..d60f855566 100644 --- a/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx @@ -1,13 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render as baseRender, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import type { FileRunEvent } from "@portal/api/fileRunEvents"; /** * Tests for the list: the states it survives (loading, empty, no registry, refused), - * plus replacing a row in place after acting and re-reading when the server refuses. + * plus replacing a row in place after acting, re-reading when the server refuses, and + * bringing itself into view when a notification links to it. */ +// jsdom does no layout and so implements no scrollIntoView. +const scrollIntoView = vi.fn(); +Element.prototype.scrollIntoView = scrollIntoView; + const fetchFileRunEvents = vi.fn(); const applyFileRunEventAction = vi.fn(); @@ -55,9 +61,19 @@ vi.mock("react-i18next", () => ({ }), })); -// The list reads through the shared query hooks, and @app/ui needs Mantine. -const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: PortalTestProviders }); +// The list reads through the shared query hooks, @app/ui needs Mantine, and the section reads +// the location to know whether it was linked to. +const render = ( + ui: Parameters[0], + at = "/processor/documents", +) => + baseRender(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); const { FileRunEventList } = await import("@portal/components/failures/FileRunEventList"); @@ -101,6 +117,7 @@ describe("FileRunEventList", () => { beforeEach(() => { fetchFileRunEvents.mockReset(); applyFileRunEventAction.mockReset(); + scrollIntoView.mockReset(); // The dev-panel test stubs import.meta.env.DEV, which would otherwise persist // into every test after it. vi.unstubAllEnvs(); @@ -215,6 +232,24 @@ describe("FileRunEventList", () => { expect(fetchFileRunEvents).toHaveBeenCalledTimes(1); }); + it("brings itself into view when a notification links to it", async () => { + // It sits below the review queue, so landing on the page is not the same as seeing it. + fetchFileRunEvents.mockResolvedValue([event()]); + + render(, "/processor/documents#failures"); + + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + }); + + it("stays where it is on an ordinary visit to the page", async () => { + fetchFileRunEvents.mockResolvedValue([event()]); + + render(); + + await screen.findByText("Password-protected document"); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + it("re-reads from the server when an action is refused", async () => { // A 409 means someone else closed it first; the server's view wins. fetchFileRunEvents.mockResolvedValue([event()]); diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx index 2330546655..31c0671f0b 100644 --- a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx @@ -1,6 +1,8 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton, StatusBadge } from "@app/ui"; +import { PORTAL_FAILURES_ANCHOR } from "@app/routes/portalBasename"; import type { FileRunEvent, FailureSeverity } from "@portal/api/fileRunEvents"; import { useFileRunEvents, @@ -30,6 +32,15 @@ export function FileRunEventList() { const [busy, setBusy] = useState<{ id: string; action: string } | null>(null); const [showJson, setShowJson] = useState(false); const [clearing, setClearing] = useState(false); + const section = useRef(null); + const { hash, key } = useLocation(); + + // A fragment is only honoured on a real page load, not a client-side route change. Keyed on the + // navigation too: a second notification changes neither the path nor the hash. + useEffect(() => { + if (hash !== `#${PORTAL_FAILURES_ANCHOR}`) return; + section.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, [hash, key]); // A build without the proprietary module has no such route, and a caller who is // not a team leader gets a 403. Both mean there is nothing to show. @@ -122,7 +133,11 @@ export function FileRunEventList() { } return ( -
    +

    {t("portal.failures.title", "Failures")}

    diff --git a/frontend/editor/src/portal/views/Documents.tsx b/frontend/editor/src/portal/views/Documents.tsx index f47abd3ffe..6bd394c13d 100644 --- a/frontend/editor/src/portal/views/Documents.tsx +++ b/frontend/editor/src/portal/views/Documents.tsx @@ -107,9 +107,13 @@ export function Documents() { - {/* Recorded policy-run failures. Not mounted outside dev: the endpoints are live and - gated, but the surface itself is unfinished (no polling, no paging, no filters). - Vite folds this to false in a build, so neither the view nor its fetch ships. */} + {/* Recorded policy, pipeline and editor failures. DEV ONLY, deliberately: this list is a + stand-in until failures get their own review screen, and it is not the surface we want to + ship. The endpoints behind it are live and gated, so nothing here is load-bearing. + + Vite folds this to false in a build, so neither the section nor its fetch ships. The bell's + "View in processor" action is gated the same way and for the same reason - lift both + together when the review screen lands, or that button navigates nowhere. */} {import.meta.env.DEV && } ); diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx new file mode 100644 index 0000000000..950a8d41ff --- /dev/null +++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx @@ -0,0 +1,271 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import type { + AppNotification, + NotificationActionOffer, +} from "@app/services/notifications"; +import type { NotificationActionContext } from "@core/components/notifications/notificationActions"; + +/** + * Where each action sends the reader. Only the editor has the workbench contexts above it, so the two + * shells are the interesting cases: opening the document, or handing it over. + */ + +const navigate = vi.fn(); +vi.mock("react-router-dom", async () => ({ + ...(await vi.importActual( + "react-router-dom", + )), + useNavigate: () => navigate, +})); + +// No i18n instance here, and the plugin is stubbed because the contexts below reach `core/i18n`. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string) => fallback, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +// IndexedDB, which jsdom has none of. Answered here so presence is a fact of the test. +const h = vi.hoisted(() => ({ + stub: { id: "f-1" } as unknown, + getStirlingFileStub: vi.fn(), +})); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFileStub: (fileId: string) => h.getStirlingFileStub(fileId), + }, +})); + +const { FileActionsContext, FileStoreContext } = + await import("@app/contexts/file/contexts"); +const { NavigationActionsContext } = + await import("@app/contexts/NavigationContext"); +const { ViewerContext } = await import("@app/contexts/ViewerContext"); +const { useNotificationActions } = + await import("@app/components/notifications/notificationActions"); + +const addStirlingFileStubs = vi.fn(); +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[] = []; + +function notification( + overrides: Partial = {}, +): AppNotification { + return { + id: "failure:evt-1", + source: "FAILURE", + kindId: "INPUT_PASSWORD_PROTECTED", + origin: "TOOL", + ownership: "MINE", + severity: "ERROR", + status: "NEW", + titleKey: "portal.failures.kind.inputPasswordProtected.title", + defaultTitle: "Password-protected document", + detail: "The PDF Document is passworded", + fileId: "f-1", + sourceId: null, + policyId: null, + occurrences: 3, + createdAt: "2026-08-06T00:00:00Z", + lastSeenAt: "2026-08-06T00:00:00Z", + actions: [], + ...overrides, + }; +} + +function offer(id: string): NotificationActionOffer { + return { + id, + labelKey: `portal.failures.action.${id.toLowerCase()}`, + defaultLabel: id, + enabled: true, + disabledReasonKey: null, + }; +} + +function context( + overrides: Partial = {}, +): NotificationActionContext { + return { + notification: notification(), + hasLocalFile: true, + ...overrides, + }; +} + +/** The editor shell: the workbench's providers all sit above the bell. */ +const inEditor = ({ children }: { children: ReactNode }) => ( + + + ({ files: { ids: openFileIds } }), + subscribe: () => () => {}, + selectors: {}, + } as never + } + > + + + {children} + + + + + +); + +/** The processor shell: the portal mounts above the app's providers, so there is none. */ +const inProcessor = ({ children }: { children: ReactNode }) => ( + {children} +); + +function registry(wrapper = inEditor) { + return renderHook(() => useNotificationActions(), { wrapper }).result.current; +} + +beforeEach(() => { + navigate.mockReset(); + addStirlingFileStubs.mockReset().mockResolvedValue([]); + setActiveFileId.mockReset(); + setWorkbench.mockReset(); + h.getStirlingFileStub.mockReset().mockResolvedValue(h.stub); + openFileIds = []; + window.sessionStorage.clear(); + window.history.pushState({}, "", "/"); +}); + +describe("useNotificationActions", () => { + it("offers to open the document only while it is still in this browser", () => { + const actions = registry(); + + expect(actions.VIEW_FILE?.available(context())).toBe(true); + expect(actions.VIEW_FILE?.available(context({ hasLocalFile: false }))).toBe( + false, + ); + }); + + it("leaves View file as the only usable offer when the server offers actions this build cannot run", () => { + // An id this build wires nothing for drops out rather than rendering dead. + const actions = registry(); + const usable = [offer("QUARANTINE"), offer("VIEW_FILE")].filter( + (candidate) => actions[candidate.id]?.available(context()) ?? false, + ); + + expect(usable.map((candidate) => candidate.id)).toEqual(["VIEW_FILE"]); + }); + + it("opens the document into the viewer when an editor is above", async () => { + await registry().VIEW_FILE?.run(context()); + + // Selecting alone shows nothing: the workbench holds neither the file nor the viewer yet. + expect(addStirlingFileStubs).toHaveBeenCalledWith([h.stub]); + expect(setActiveFileId).toHaveBeenCalledWith("f-1"); + expect(setWorkbench).toHaveBeenCalledWith("viewer"); + }); + + it("stays where it is rather than routing through the role-based root", async () => { + // "/" lands on a page chosen by the reader's role, which reads as the app reloading. + await registry().VIEW_FILE?.run(context()); + + expect(window.location.pathname).toBe("/"); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("does not add a document the workbench is already holding", async () => { + openFileIds = ["f-1"]; + + await registry().VIEW_FILE?.run(context()); + + expect(addStirlingFileStubs).not.toHaveBeenCalled(); + // Still brought to the front: the point of the click is to look at it. + expect(setActiveFileId).toHaveBeenCalledWith("f-1"); + expect(setWorkbench).toHaveBeenCalledWith("viewer"); + }); + + it("reports a document that has gone from storage instead of opening nothing", async () => { + h.getStirlingFileStub.mockResolvedValue(null); + + const outcome = await registry().VIEW_FILE?.run(context()); + + expect(outcome).toEqual({ ok: false }); + expect(setWorkbench).not.toHaveBeenCalled(); + }); + + it("hands the document over to the editor when there is no workbench above it", async () => { + await registry(inProcessor).VIEW_FILE?.run(context()); + + // The intent outlives the navigation that mounts the editor. + expect( + window.sessionStorage.getItem("stirling.notifications.pendingSelection"), + ).toBe("f-1"); + // The editor's own URL, not the role router at "/". + expect(window.location.pathname).toBe("/editor"); + }); + + it("picks up a handed-over document as soon as an editor is there", async () => { + window.sessionStorage.setItem( + "stirling.notifications.pendingSelection", + "f-9", + ); + + registry(); + await vi.waitFor(() => expect(setActiveFileId).toHaveBeenCalledWith("f-9")); + + expect(setWorkbench).toHaveBeenCalledWith("viewer"); + // One-shot: a later mount must not reopen a document the user has moved on from. + expect( + window.sessionStorage.getItem("stirling.notifications.pendingSelection"), + ).toBeNull(); + }); + + it("says it cannot hand the document over rather than navigating to nothing", async () => { + // Spied on the prototype: jsdom's storage is a proxy, so an own-property spy does not take. + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("denied"); + }); + + const outcome = await registry(inProcessor).VIEW_FILE?.run(context()); + + expect(outcome).toEqual({ + ok: false, + message: + "This browser will not let the processor pass the document to the editor. Open it from the editor instead.", + }); + // Still on the page it started on, so the failure is visible. + expect(window.location.pathname).toBe("/"); + setItem.mockRestore(); + }); + + it("links to the recorded failures section of the processor", () => { + registry().VIEW_IN_PROCESSOR?.run(context()); + + expect(navigate).toHaveBeenCalledWith("/processor/documents#failures"); + }); + + it("offers the processor link whenever the server did", () => { + // The server only sends it to someone it will let read the queue. + expect( + registry(inProcessor).VIEW_IN_PROCESSOR?.available( + context({ hasLocalFile: false }), + ), + ).toBe(true); + }); +}); diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts new file mode 100644 index 0000000000..5f34561a5a --- /dev/null +++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts @@ -0,0 +1,155 @@ +import { useCallback, useContext, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { withBasePath } from "@app/constants/app"; +import { + FileActionsContext, + FileStoreContext, +} from "@app/contexts/file/contexts"; +import { NavigationActionsContext } from "@app/contexts/NavigationContext"; +import { ViewerContext } from "@app/contexts/ViewerContext"; +import { + PORTAL_BASENAME, + PORTAL_FAILURES_ANCHOR, +} from "@app/routes/portalBasename"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { fileStorage } from "@app/services/fileStorage"; +import type { FileId } from "@app/types/file"; +import { + type ClientActionOutcome, + type ClientActionRegistry, + type ClientActionSpec, + type NotificationActionContext, +} from "@core/components/notifications/notificationActions"; + +export { + type ClientActionOutcome, + type ClientActionRegistry, + type ClientActionSpec, + type NotificationActionContext, +}; + +/** + * The portal mounts as a sibling of `AppProviders`, so in the processor shell none of the workbench + * contexts exist above this hook. That is why contexts are read raw and a document is handed over. + */ + +const HANDOFF_KEY = "stirling.notifications.pendingSelection"; + +const FAILURES_DESTINATION = `${PORTAL_BASENAME}/documents#${PORTAL_FAILURES_ANCHOR}`; + +/** False when storage refused it: navigating anyway lands the user in an editor with nothing open. */ +function stashSelection(fileId: string): boolean { + try { + window.sessionStorage.setItem(HANDOFF_KEY, fileId); + return true; + } catch { + return false; + } +} + +function takeSelection(): string | null { + try { + const fileId = window.sessionStorage.getItem(HANDOFF_KEY); + if (fileId !== null) window.sessionStorage.removeItem(HANDOFF_KEY); + return fileId; + } catch { + return null; + } +} + +/** + * Not the router's `navigate`: the editor reads its tool from the URL on mount and on a history pop, + * and a router push is neither, so the address would change and the workbench would not. + */ +function goToEditor(path: string): void { + window.history.pushState({}, "", withBasePath(path)); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function useNotificationActions(): ClientActionRegistry { + const { t } = useTranslation(); + const navigate = useNavigate(); + // Raw, because the hooks that wrap these throw when there is no provider, and in the processor + // shell there is none. All four are present together or not at all. + const fileContext = useContext(FileActionsContext); + const fileStore = useContext(FileStoreContext); + const navigation = useContext(NavigationActionsContext); + const viewer = useContext(ViewerContext); + const canOpenHere = Boolean(fileContext && fileStore && navigation && viewer); + + /** + * Opens the way the file sidebar does. Selecting alone shows nothing: an id the workbench does not + * hold has nothing to render, and the workbench keeps whatever view it was on. + */ + const openInWorkbench = useCallback( + async (fileId: string): Promise => { + if (!fileContext || !fileStore || !navigation || !viewer) return false; + + const stub = await fileStorage.getStirlingFileStub(fileId as FileId); + if (!stub) return false; + + const alreadyOpen = fileStore + .getState() + .files.ids.some((id) => (id as string) === fileId); + if (!alreadyOpen) { + await fileContext.actions.addStirlingFileStubs([stub]); + } + viewer.setActiveFileId(fileId); + navigation.actions.setWorkbench("viewer"); + return true; + }, + [fileContext, fileStore, navigation, viewer], + ); + + // One-shot: read and cleared, so a later render cannot reopen a file the user has moved on from. + useEffect(() => { + if (!canOpenHere) return; + const fileId = takeSelection(); + if (fileId) void openInWorkbench(fileId); + }, [canOpenHere, openInWorkbench]); + + return useMemo(() => { + const openDocument = async ( + fileId: string | null, + ): Promise => { + if (!fileId) return; + + // In place, with no navigation: "/" is the role-based router, so going there reads as the app + // reloading and lands the user wherever their role says rather than on their document. + if (canOpenHere) { + return (await openInWorkbench(fileId)) ? undefined : { ok: false }; + } + + if (!stashSelection(fileId)) { + 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(EDITOR_BASENAME); + }; + + const viewFile: ClientActionSpec = { + available: (context) => context.hasLocalFile, + closesPanel: true, + run: (context) => openDocument(context.notification.fileId), + }; + + const viewInProcessor: ClientActionSpec = { + // Its destination is dev-only until failures get a review screen; the other half of this gate + // is in portal/views/Documents, and both lift together. + available: () => import.meta.env.DEV, + closesPanel: true, + run: () => navigate(FAILURES_DESTINATION), + }; + + return { + VIEW_FILE: viewFile, + VIEW_IN_PROCESSOR: viewInProcessor, + }; + }, [canOpenHere, openInWorkbench, navigate, t]); +} diff --git a/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts b/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts new file mode 100644 index 0000000000..2e21f0f33e --- /dev/null +++ b/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts @@ -0,0 +1,7 @@ +/** + * This build ships the failure registry and the notification routes, so the bell has + * something to read and may mount. + */ +export function useNotificationsAvailable(): boolean { + return true; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx index b87cbda445..a6f78392be 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx @@ -91,7 +91,12 @@ describe("auto-run ordered chaining", () => { // The first policy (order 0) runs on the upload; the second waits for the chain. expect(runStored).toHaveBeenCalledTimes(1); - expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]); + // Recorded against a document this browser can resolve, which is what makes its failure actionable. + expect(runStored).toHaveBeenCalledWith( + "backend-sec", + [{ size: 100 }], + "file-1", + ); }); it("chains the next policy onto a completed run's output", async () => { @@ -120,8 +125,12 @@ describe("auto-run ordered chaining", () => { await vi.advanceTimersByTimeAsync(1); }); - // The next policy (order 1) fires on the first policy's output, not the original. - expect(runStored).toHaveBeenCalledWith("backend-cls", [{ size: 100 }]); + // Fires on the first policy's output and reports that output's own id, not the original's. + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); }); it("keeps classification out of the server chain when the AI engine is off", async () => { @@ -136,10 +145,31 @@ describe("auto-run ordered chaining", () => { await vi.advanceTimersByTimeAsync(1); }); - expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]); + expect(runStored).toHaveBeenCalledWith( + "backend-sec", + [{ size: 100 }], + "file-1", + ); expect(runStored).not.toHaveBeenCalledWith( "backend-cls", expect.anything(), + expect.anything(), ); }); + + it("never dispatches on a file marked derivedFromTool", async () => { + // A policy run is billed, so this gate is what stops `importOutputs` re-enforcing a policy on + // its own output forever. If this fails, fix the gate rather than the test. + setFileStubs([ + { id: "file-1", name: "unlocked.pdf", derivedFromTool: true }, + ]); + runStored.mockResolvedValue("run-sec"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index 3c54e5cc42..b837b4beba 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -113,7 +113,13 @@ describe("auto-run queue-rejection retry", () => { await act(async () => { await vi.advanceTimersByTimeAsync(6000); }); - expect(runStored).toHaveBeenCalledWith("backend-1", [{ size: 1234 }]); + // The workspace id travels with the retry too, so a failure of it can still name the + // document this browser is holding. + expect(runStored).toHaveBeenCalledWith( + "backend-1", + [{ size: 1234 }], + "file-1", + ); expect(getRun("run-1")).toBeUndefined(); expect(getRun("run-2")?.status).toBe("RUNNING"); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 8140691b6d..936fef8119 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -10,6 +10,7 @@ import { useFileContext, } from "@app/contexts/FileContext"; import { fileStorage } from "@app/services/fileStorage"; +import { refreshNotificationsNow } from "@app/hooks/useNotifications"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import i18n from "@app/i18n"; import { @@ -241,6 +242,8 @@ export function usePolicyAutoRun(): void { dispatchKey(finished.categoryId, finished.fileId), ); } + // Read now rather than leaving them a poll interval to hear about their own upload. + if (view.status === "FAILED") refreshNotificationsNow(); const code = view.errorCode; if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return; if (firedLimitModal.current.has(view.runId)) return; @@ -881,7 +884,9 @@ async function runPolicyOnFile( await acquireDispatchSlot(priority); try { const target = resolvePolicyRunTarget(); - const runId = await runStoredPolicy(backendId, [file]); + // Recorded against a document this browser can resolve. One file per run, which is the only + // shape the server keeps a reference for. + const runId = await runStoredPolicy(backendId, [file], fileId); // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ runId, diff --git a/frontend/editor/src/proprietary/services/policyApi.test.ts b/frontend/editor/src/proprietary/services/policyApi.test.ts new file mode 100644 index 0000000000..ae0b861e54 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyApi.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Only the document reference is pinned, being the one field with a rule attached: a filename here + * would reach a table that deliberately has nowhere to keep one. + */ + +const post = vi.fn().mockResolvedValue({ data: { jobId: "run-1" } }); + +vi.mock("@app/services/apiClient", () => ({ + default: { post: (...args: unknown[]) => post(...args) }, +})); + +const { runStoredPolicy } = await import("@app/services/policyApi"); + +/** The multipart body the last call sent. */ +function sentForm(): FormData { + return post.mock.calls.at(-1)?.[1] as FormData; +} + +const document = () => + new File(["%PDF-1.7"], "quarterly-report.pdf", { type: "application/pdf" }); + +describe("runStoredPolicy", () => { + beforeEach(() => { + post.mockClear(); + }); + + it("sends the workspace id of the document it is running on", async () => { + await runStoredPolicy("policy-1", [document()], "editor-file-1"); + + expect(post.mock.calls.at(-1)?.[0]).toBe("/api/v1/policies/policy-1/run"); + expect(sentForm().get("fileId")).toBe("editor-file-1"); + }); + + it("sends no reference when the caller has none", async () => { + // The export path can enforce on bytes with no workspace document behind them. + await runStoredPolicy("policy-1", [document()]); + + expect(sentForm().has("fileId")).toBe(false); + }); + + it("never sends the document's name as the reference", async () => { + await runStoredPolicy("policy-1", [document()], "editor-file-1"); + + expect(sentForm().get("fileId")).not.toContain("quarterly-report"); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index b0d78f36f7..d93e51fa82 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -60,13 +60,18 @@ export async function reorderPolicies(orderedIds: string[]): Promise { await apiClient.put("/api/v1/policies/order", orderedIds); } -/** Run a stored policy by id on the supplied files; returns the run id. */ +/** + * Run a stored policy by id; returns the run id. `fileId` is this workspace's own opaque id, recorded + * against any failure of the run. Only honoured for a single-document run, and never a filename. + */ export async function runStoredPolicy( id: string, files: File[], + fileId?: string, ): Promise { const form = new FormData(); for (const file of files) form.append("fileInput", file); + if (fileId) form.append("fileId", fileId); // Don't set Content-Type: the HTTP client must generate multipart/form-data // WITH its boundary from the FormData body. A manual boundary-less header makes // the server reject the request ("no multipart boundary parameter").