Failure action slots, resolve transition, and the bell that renders them (Review Flow PR 5a) (#7761)

Review Flow PR 5a — the first half of #7479, which stays open for
reference until both halves land. This PR is the ranking and the
bookkeeping; #7762 adds the retry handlers. Merging both reproduces
#7479's diff byte-for-byte.

## What's added

**The action slot model (backend).** `FailureActionSlot` ranks each of a
kind's offers as its `RESOLUTION`, `SECONDARY` or `OVERFLOW`.
`FailureKind` now declares placement per offer — the password-protected
kind names `DECRYPT_AND_RETRY` as its resolution, `UNKNOWN` leads with a
plain `RETRY` — and `FailureActionId` gains those two ids. The
declarations are data; their client handlers arrive in the follow-up, so
this build withholds them with a reason rather than rendering unwired
buttons (the same forward-compatibility #7478 relied on).

**A resolve transition.** `POST /api/v1/notifications/{id}/resolved`
lets a client report a failure fixed. `NotificationSource.parse` turns a
qualified notification id back into the source that owns it, and
`FileRunEventService` folds the resolution into the incident rather than
deleting it.

**`viewerReviewsTeam` on the list response.** A member sees only rows
whose document this browser holds — they can neither open nor fix
anything else — while a team reviewer keeps every row.

**The bell renders the ranking** (`promoteActions`): one primary button,
at most one secondary, the rest in an overflow menu beside **Copy log**.
The row's body is the kind's own sentence; the raw failure message moves
into the menu.

**Read state is a timestamp, not a row id.** `readThroughAt` replaces
`lastSeenId`: when a resolved or dismissed row leaves the list, the rows
below it stay read instead of re-lighting the badge.

## How to test

Needs a proprietary or SaaS build with login enabled (`task dev:all`,
sign in).

1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now**; the upload's policy run fails on it.
2. **Open the bell.** The row reads the kind's sentence, not a stack
trace. Its primary button is **View file** — the server offers Decrypt
and retry as the resolution, but this build withholds it (handler lands
in the follow-up), so the best renderable offer is promoted instead.
3. **Open the row's ⋯ menu.** View in processor and Dismiss sit there,
along with **Copy log**, which copies the raw message.
4. **Check the read marker survives a departure.** With two failures,
open the bell (badge clears), dismiss the newer row, and refresh: the
badge stays dark. On main, the marker held the departed row's id and the
older row re-read as unread.
5. **Member visibility.** As a plain member, a failure recorded from
another browser does not appear in the bell; as a team reviewer it does.
6. **Resolve endpoint.** `POST
/api/v1/notifications/failure-{eventId}/resolved` as the owner removes
the row on the next poll; `NotificationResolveTest` pins refusal for a
non-owner, an unknown id, and a foreign prefix.

## Migration

None.
This commit is contained in:
EthanHealy01
2026-09-01 13:25:19 +00:00
committed by GitHub
parent ceeec53df4
commit f6661a8f87
35 changed files with 1597 additions and 341 deletions
@@ -18,6 +18,19 @@ public enum FailureActionId {
DISMISS(Execution.SERVER, "Dismiss"),
/**
* Open the failed operation in the client with its document, for the owner to run again
* themselves. Not a re-run: the settings are theirs to check first.
*/
OPEN_IN_TOOL(Execution.CLIENT, "Retry"),
/**
* Ask the owner for the password and unlock the document in their client. Re-running is implied
* rather than named: an id says what a caller must supply, and a {@link
* FailureActionSlot#RESOLUTION} runs the failed work again once it has it.
*/
DECRYPT(Execution.CLIENT, "Decrypt and retry"),
/** Open the document behind the incident, in whichever client can resolve its id. */
VIEW_FILE(Execution.CLIENT, "View file"),
@@ -0,0 +1,14 @@
package stirling.software.proprietary.failure;
/** Placement intent, not layout: the client promotes, knowing what it can actually run. */
public enum FailureActionSlot {
/** The action that resolves the failure. At most one per kind. */
RESOLUTION,
/** Offered alongside the resolution, for a caller the resolution is not aimed at. */
SECONDARY,
/** Available but folded away: correct, rarely what anyone wants to press next. */
OVERFLOW
}
@@ -1,8 +1,12 @@
package stirling.software.proprietary.failure;
import static stirling.software.proprietary.failure.FailureActionId.DECRYPT;
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
import static stirling.software.proprietary.failure.FailureActionId.OPEN_IN_TOOL;
import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE;
import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR;
import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW;
import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY;
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
@@ -21,11 +25,8 @@ import lombok.AccessLevel;
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.
*
* <p>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.
* The registry of failure kinds as data: id, i18n keys, English fallback, plus the facets a review
* surface needs. A new kind ships as an entry plus copy; each offer says who it is for and where.
*/
@Getter
public enum FailureKind {
@@ -36,9 +37,12 @@ public enum FailureKind {
FailureScope.FILE,
errorCodes("E004"),
fallback("This document is password-protected, so the pipeline could not read it."),
offer(VIEW_FILE, OWNER),
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
offer(DISMISS, ANYONE_WHO_SEES)),
// The password is the fix; the owner's own document is the runner-up.
resolution(DECRYPT, OWNER),
global(VIEW_FILE, OWNER, SECONDARY),
global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, OVERFLOW),
global(OPEN_IN_TOOL, OWNER, OVERFLOW),
global(DISMISS, ANYONE_WHO_SEES, OVERFLOW)),
UNKNOWN(
FailureStage.INTERNAL,
@@ -47,11 +51,11 @@ public enum FailureKind {
FailureScope.RUN,
noErrorCodes(),
fallback("This run failed for a reason Stirling does not yet recognise."),
// 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));
// No known fix to declare, so a plain retry leads: these are often one-offs.
global(OPEN_IN_TOOL, OWNER, SECONDARY),
global(VIEW_FILE, OWNER, SECONDARY),
global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, OVERFLOW),
global(DISMISS, ANYONE_WHO_SEES, OVERFLOW));
private static final String KEY_PREFIX = "portal.failures.kind.";
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
@@ -98,27 +102,37 @@ public enum FailureKind {
this.offers = List.of(offers);
}
/**
* 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, FailureAudience audience, String labelKeySuffix) {}
/** One ordered list, not parallel maps of audiences, slots and labels that could disagree. */
private record Offer(
FailureActionId id,
FailureAudience audience,
FailureActionSlot slot,
String labelKeySuffix) {}
/** Declaration order is display order. */
private static Offer offer(FailureActionId id, FailureAudience audience) {
return new Offer(id, audience, null);
/** The action that fixes this kind. One per kind: needing two would make it two kinds. */
private static Offer resolution(FailureActionId id, FailureAudience audience) {
return new Offer(id, audience, FailureActionSlot.RESOLUTION, null);
}
/**
* As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording
* where the shared one reads badly.
*/
private static Offer offer(
/** As {@link #resolution(FailureActionId, FailureAudience)}, with this kind's own wording. */
private static Offer resolution(
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
return new Offer(id, audience, labelKeySuffix);
return new Offer(id, audience, FailureActionSlot.RESOLUTION, labelKeySuffix);
}
/** Not this kind's fix: an offer any kind can make, with the shared wording. */
private static Offer global(
FailureActionId id, FailureAudience audience, FailureActionSlot slot) {
return new Offer(id, audience, slot, null);
}
/** As above, with this kind's own wording where the shared one reads badly. */
private static Offer global(
FailureActionId id,
FailureAudience audience,
FailureActionSlot slot,
String labelKeySuffix) {
return new Offer(id, audience, slot, labelKeySuffix);
}
/**
@@ -157,21 +171,25 @@ 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.
*/
/** What this kind offers, in declaration order, each with label and placement resolved. */
public List<OfferedAction> getOfferedActions() {
return offers.stream()
.map(
offer ->
new OfferedAction(
offer.id(), labelKeyFor(offer.id()), offer.audience()))
offer.id(),
labelKeyFor(offer.id()),
offer.audience(),
offer.slot()))
.toList();
}
/** One action as a kind declares it: what to call it and who it is for. */
public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {}
/** One action as a kind declares it: what to call it, who it is for, where it wants to sit. */
public record OfferedAction(
FailureActionId id,
String labelKey,
FailureAudience audience,
FailureActionSlot slot) {}
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
public boolean declares(FailureActionId action) {
@@ -64,16 +64,17 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
int fold(@Param("id") String id, @Param("now") Instant now, @Param("detail") String detail);
/**
* Reopen a resolved incident whose failure has recurred. Guarded on the current status so only
* {@code RESOLVED} flips; a concurrent dismiss is never overwritten back to {@code NEW}.
* A recurrence reopens {@code RESOLVED} (the fix did not hold) and {@code FILE_REMOVED} (the
* document is back). Guarded, so a reviewer's {@code DISMISSED} is never overwritten.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set"
+ " e.status = stirling.software.proprietary.failure.FileRunEventStatus.NEW,"
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status ="
+ " stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED")
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status in"
+ " (stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED,"
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED)")
int reopenIfResolved(@Param("id") String id);
/**
@@ -1,5 +1,9 @@
package stirling.software.proprietary.failure;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@@ -171,6 +175,18 @@ public class FileRunEventService {
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
}
/** Mark an incident resolved after a client's own retry worked. Idempotent. */
public FileRunEvent resolve(String eventId) {
FileRunEvent event = requireVisible(eventId);
// No terminal pre-check: the store's guarded UPDATE decides, rather than racing a read.
return store.applyStatusOnce(
event.id(),
event.teamId(),
FileRunEventStatus.RESOLVED,
currentActor(),
FileRunEventStatus.open());
}
/** "No such event" rather than a refusal, so trying does not confirm a colleague's exists. */
private FileRunEvent requireVisible(String eventId) {
ReadScope scope = readScope();
@@ -222,7 +238,8 @@ public class FileRunEventService {
boolean unattended,
boolean documentless) {
String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless);
return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason);
return new AvailableAction(
offer.id(), offer.labelKey(), offer.slot(), reason == null, reason);
}
/** Closed wins over everything, then the owner-only reasons, most specific first. */
@@ -251,11 +268,38 @@ public class FileRunEventService {
};
}
/** Login disabled has no roles, so its one operator triages everything. */
private boolean reviewsTeam() {
/** Whether the caller triages the team's incidents, not only their own. Login disabled: all. */
public boolean reviewsTeam() {
return !enforced() || policyManagementAuthority.canEditPolicies();
}
/**
* An opaque, stable discriminator for the calling viewer, for a client scoping per-browser read
* state. Hashed rather than the username itself: a client only needs to tell one viewer from
* another, and the value ends up in that browser's own storage.
*
* <p>{@code "anonymous"} with login disabled, where the one operator is every viewer.
*/
public String viewerKey() {
String actor = currentActor();
return actor == null || actor.isBlank() ? "anonymous" : sha256Prefix(actor);
}
/** First 8 bytes of SHA-256 as hex: stable, one-way, and collision-safe enough to key on. */
private static String sha256Prefix(String value) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException e) {
// Every JVM ships SHA-256; a constant here would silently merge two viewers' read
// state, so the caller gets no key and the client falls back to showing everything.
log.warn("SHA-256 unavailable, so notifications cannot be scoped to a viewer", e);
return "";
}
}
private FailureActionId parseActionId(String actionId) {
for (FailureActionId candidate : FailureActionId.values()) {
if (candidate.name().equals(actionId)) {
@@ -326,6 +370,11 @@ public class FileRunEventService {
return applicationProperties.getSecurity().isEnableLogin();
}
/** One action offered to one caller, availability resolved. */
public record AvailableAction(
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
FailureActionId id,
String labelKey,
FailureActionSlot slot,
boolean enabled,
String disabledReasonKey) {}
}
@@ -3,10 +3,7 @@ package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.List;
/**
* Disposition of one recorded failure. {@code RESOLVED} is declared but not set yet (it becomes
* system-set later); the rollup already defines what a repeat means for it, which is to reopen.
*/
/** Disposition of one recorded failure. {@code RESOLVED} is system-set; a repeat reopens it. */
public enum FileRunEventStatus {
NEW(false),
ACKNOWLEDGED(false),
@@ -14,9 +11,8 @@ public enum FileRunEventStatus {
RESOLVED(true),
/**
* The document this incident was about was deleted from its owner's editor, so there is nothing
* left to act on. Distinct from {@code DISMISSED}, which is a reviewer's decision, and from
* {@code RESOLVED}, which reopens on recurrence: this one cannot recur, the file is gone.
* The document was deleted, so there is nothing left to act on. A recurrence reopens it like
* {@code RESOLVED}: a fresh failure is proof the document is back.
*/
FILE_REMOVED(true);
@@ -61,14 +61,15 @@ public record FileRunEventView(
}
/**
* {@code defaultLabel} and {@code execution} let a client render and route an action it was
* never built with. Declaration order is display order.
* {@code defaultLabel} and {@code execution} let a client render an action it was never built
* with; {@code slot} is placement intent. See {@link FailureActionSlot}.
*/
public record ActionView(
String id,
String labelKey,
String defaultLabel,
FailureActionId.Execution execution,
FailureActionSlot slot,
boolean enabled,
String disabledReasonKey) {
@@ -78,6 +79,7 @@ public record FileRunEventView(
action.labelKey(),
action.id().getDefaultLabel(),
action.id().getExecution(),
action.slot(),
action.enabled(),
action.disabledReasonKey());
}
@@ -2,10 +2,14 @@ package stirling.software.proprietary.notification;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
@@ -13,9 +17,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.failure.FailureActionException;
/**
* 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.
* Open to any authenticated user: each source scopes its own rows. Every action runs on the
* client's own device, so the only write is it reporting a fix.
*/
@RestController
@RequestMapping("/api/v1/notifications")
@@ -40,9 +46,34 @@ public class NotificationController {
+ " 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));
return new NotificationsResponse(
notifications.list(capped),
notifications.callerReviewsTeam(),
notifications.callerViewerKey());
}
@PostMapping("/{notificationId}/resolved")
@Operation(
summary = "Record that a client-side retry fixed what a notification was about",
description =
"Takes the prefixed notification id, not the producing row's id. Not an action:"
+ " nobody is offered a resolve button, and a recurrence brings the"
+ " notification back.")
public NotificationView resolved(@PathVariable String notificationId) {
try {
return notifications.resolve(notificationId);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
} catch (FailureActionException e) {
throw new ResponseStatusException(
FailureActionException.statusOf(e.getReason()), e.getMessage(), e);
}
}
/** Wrapped so paging or a total can be added without breaking clients. */
public record NotificationsResponse(List<NotificationView> notifications) {}
public record NotificationsResponse(
List<NotificationView> notifications,
boolean viewerReviewsTeam,
/** Opaque; the client scopes its own read state on it. Empty means "cannot scope". */
String viewerKey) {}
}
@@ -20,9 +20,47 @@ public class NotificationService {
private final FileRunEventService fileRunEvents;
/** Newest first, and only open failures: one already dealt with is not news. */
/**
* Newest first, and only open failures about a document: one already dealt with is not news,
* and a row naming no file has nothing the bell can offer beyond saying so.
*
* <p>Filtered on the named file rather than the kind's scope, because a RUN-scoped kind still
* names one when the editor reported it: a failed tool run belongs here. Applied after the
* limit, so a page can come back short while unattributed rows exist - the review surface is
* where those are meant to be read, and it lists them unfiltered.
*/
public List<NotificationView> list(int limit) {
return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList();
return fileRunEvents.list(null, null, limit).stream()
.filter(event -> event.fileId() != null && !event.fileId().isBlank())
.map(this::fromFailure)
.toList();
}
/** Whether the caller sees the whole team's incidents rather than only their own. */
public boolean callerReviewsTeam() {
return fileRunEvents.reviewsTeam();
}
/** Opaque and stable, so a shared browser can keep one viewer's read state off another's. */
public String callerViewerKey() {
return fileRunEvents.viewerKey();
}
/** Takes the prefixed id, so the bell cannot reach a failure endpoint even by accident. */
public NotificationView resolve(String notificationId) {
NotificationSource.QualifiedId qualified = qualify(notificationId);
return switch (qualified.source()) {
case FAILURE -> fromFailure(fileRunEvents.resolve(qualified.rowId()));
};
}
/** The source and row id behind a notification id, refusing anything that is not one. */
private static NotificationSource.QualifiedId qualify(String notificationId) {
return NotificationSource.parse(notificationId)
.orElseThrow(
() ->
new IllegalArgumentException(
"Not a notification id: " + notificationId));
}
/** Prefixes the row id on the way out, so it is never sent bare. */
@@ -1,6 +1,8 @@
package stirling.software.proprietary.notification;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
/**
* Which subsystem produced a notification. Every id is prefixed with it, so a client never holds
@@ -18,4 +20,24 @@ public enum NotificationSource {
public String qualify(String sourceRowId) {
return prefix() + sourceRowId;
}
/** Empty rather than throwing for an unprefixed or unknown id: both arrive from clients. */
public static Optional<QualifiedId> parse(String notificationId) {
if (notificationId == null) {
return Optional.empty();
}
int separator = notificationId.indexOf(SEPARATOR);
if (separator <= 0 || separator == notificationId.length() - 1) {
return Optional.empty();
}
String prefix = notificationId.substring(0, separator);
String rowId = notificationId.substring(separator + 1);
return Arrays.stream(values())
.filter(source -> source.name().equalsIgnoreCase(prefix))
.findFirst()
.map(source -> new QualifiedId(source, rowId));
}
/** A notification id split into the source that owns it and that source's own row id. */
public record QualifiedId(NotificationSource source, String rowId) {}
}
@@ -357,8 +357,6 @@ class ConnectServiceTest {
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
}
// ---------------------------------------------------------------------------------------
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
@@ -43,6 +43,7 @@ class CheckConstrainedEnumsTest {
assertThat(persisted)
.doesNotContain(
FailureAudience.class,
FailureActionSlot.class,
FailureActionId.class,
FailureActionId.Execution.class,
Ownership.class);
@@ -1,6 +1,9 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW;
import static stirling.software.proprietary.failure.FailureActionSlot.RESOLUTION;
import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY;
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
@@ -34,9 +37,12 @@ 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) {
FailureActionId id,
FailureAudience audience,
FailureActionSlot slot,
String labelKeySuffix) {
return new FailureKind.OfferedAction(
id, "portal.failures.action." + labelKeySuffix, audience);
id, "portal.failures.action." + labelKeySuffix, audience, slot);
}
@Nested
@@ -70,27 +76,6 @@ class FailureKindTest {
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void declaresItsActionsInTheSameOrderAsEveryOtherKind(FailureKind kind) {
// Declaration order is display order and the first usable offer is the row's primary,
// so
// two kinds disagreeing would flip the solid button between rows.
List<FailureActionId> ranking =
List.of(
FailureActionId.VIEW_FILE,
FailureActionId.VIEW_IN_PROCESSOR,
FailureActionId.DISMISS);
List<FailureActionId> declared = kind.getActions();
assertThat(ranking)
.as("%s declares an action the shared ranking does not rank", kind.getId())
.containsAll(declared);
assertThat(declared)
.as("%s declares its actions out of the shared order", kind.getId())
.isEqualTo(ranking.stream().filter(declared::contains).toList());
}
@Test
void idsAreUnique() {
Set<String> ids = new HashSet<>();
@@ -121,12 +106,13 @@ 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.
void everyOfferSaysWhoItIsForAndWhereItGoes(FailureKind kind) {
// Both decide what a caller is shown, so a missing one places a button by accident.
for (FailureKind.OfferedAction offer : kind.getOfferedActions()) {
assertThat(offer.audience())
.as("%s offers %s", kind.getId(), offer.id())
.isNotNull();
assertThat(offer.slot()).as("%s offers %s", kind.getId(), offer.id()).isNotNull();
}
}
@@ -138,6 +124,17 @@ class FailureKindTest {
assertThat(kind.getActions()).doesNotHaveDuplicates();
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void declaresAtMostOneResolution(FailureKind kind) {
// Two things that both claim to fix it is a sign of two kinds wearing one id.
assertThat(
kind.getOfferedActions().stream()
.filter(offer -> offer.slot() == FailureActionSlot.RESOLUTION)
.toList())
.hasSizeLessThanOrEqualTo(1);
}
@Test
void noTwoKindsClaimTheSameErrorCode() {
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
@@ -232,16 +229,18 @@ class FailureKindTest {
class Unknown {
@Test
void offersItsOwnerTheirDocumentAndTheRunToWhoeverReviews() {
// Nothing here is known to be fixable, so the offers are just the places to look.
void offersARetryToItsOwnerAndTheRunToWhoeverReviews() {
// No known fix, so no resolution; a retry is still worth offering for a one-off.
assertThat(FailureKind.UNKNOWN.getOfferedActions())
.containsExactly(
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
offered(FailureActionId.OPEN_IN_TOOL, OWNER, SECONDARY, "openInTool"),
offered(FailureActionId.VIEW_FILE, OWNER, SECONDARY, "viewFile"),
offered(
FailureActionId.VIEW_IN_PROCESSOR,
TEAM_REVIEWER,
OVERFLOW,
"viewInProcessor"),
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss"));
}
@Test
@@ -294,16 +293,19 @@ class FailureKindTest {
}
@Test
void offersTheDocumentToItsOwnerAndTheRunToItsReviewer() {
// The point of the audiences: only the owner holds the document.
void aKindWithSomethingToFixOffersTheFixToItsOwnerAndTheRunToItsReviewer() {
// Only the owner has the password, so a reviewer is offered the run and a dismiss.
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions())
.containsExactly(
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
offered(FailureActionId.DECRYPT, OWNER, RESOLUTION, "decrypt"),
offered(FailureActionId.VIEW_FILE, OWNER, SECONDARY, "viewFile"),
offered(
FailureActionId.VIEW_IN_PROCESSOR,
TEAM_REVIEWER,
OVERFLOW,
"viewInProcessor"),
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
offered(FailureActionId.OPEN_IN_TOOL, OWNER, OVERFLOW, "openInTool"),
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss"));
}
@Test
@@ -333,10 +335,8 @@ class FailureKindTest {
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");
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DECRYPT))
.isEqualTo("portal.failures.action.decrypt");
}
@Test
@@ -153,6 +153,7 @@ class FileRunEventControllerTest {
action -> {
assertThat(action.defaultLabel()).isNotBlank();
assertThat(action.execution()).isNotNull();
assertThat(action.slot()).isNotNull();
})
.filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id()))
.singleElement()
@@ -160,6 +161,7 @@ class FileRunEventControllerTest {
action -> {
assertThat(action.execution())
.isEqualTo(FailureActionId.Execution.CLIENT);
assertThat(action.slot()).isEqualTo(FailureActionSlot.OVERFLOW);
assertThat(action.defaultLabel()).isEqualTo("View in processor");
});
}
@@ -130,6 +130,7 @@ class FileRunEventHttpIntegrationTest {
assertThat(actions.get(0).get("defaultLabel").asString())
.isEqualTo("View in processor");
assertThat(actions.get(0).get("execution").asString()).isEqualTo("CLIENT");
assertThat(actions.get(0).get("slot").asString()).isEqualTo("OVERFLOW");
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");
@@ -157,6 +157,103 @@ class FileRunEventServiceTest {
}
}
@Nested
@DisplayName("resolve")
class Resolve {
@Test
void marksTheRowResolvedWhenAClientReportsItsRetryWorked() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
FileRunEvent resolved = service.resolve(event.id());
assertThat(resolved.status()).isEqualTo(FileRunEventStatus.RESOLVED);
assertThat(resolved.statusActor()).isEqualTo(ACTOR);
assertThat(service.list(null, null, 10)).as("resolved work is not open work").isEmpty();
}
@Test
void isNotAnActionAnyoneCanPress() {
// System-set on a client-side retry, so there is no id to dispatch and no button.
assertThat(Arrays.stream(FailureActionId.values()).map(Enum::name))
.doesNotContain("RESOLVE", "RESOLVED");
}
@Test
void reportingTheSameSuccessTwiceIsNotARefusal() {
// A client that retries, succeeds and reports twice is telling the truth twice.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
Instant first = service.resolve(event.id()).statusAt();
assertThat(service.resolve(event.id()).statusAt()).isEqualTo(first);
}
@Test
void aDismissedRowCannotBeResolvedBehindTheReviewersBack() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
service.dispatch(event.id(), "DISMISS", Map.of());
assertThatThrownBy(() -> service.resolve(event.id()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
}
@Test
void anotherTeamsRowIsNotFound() {
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
assertThatThrownBy(() -> service.resolve(theirs.id()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
}
@Test
void aRecurrenceReopensIt() {
// RESOLVED claims one attempt worked, not that the problem is gone for good.
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
FileRunEvent event = service.list(null, null, 10).getFirst();
service.resolve(event.id());
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
assertThat(service.list(null, null, 10))
.singleElement()
.extracting(FileRunEvent::status)
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void aRecurrenceReopensAnIncidentClosedBecauseTheFileWasRemoved() {
// A library file comes back under the same id, so without this every repeat folds
// into the closed row and the queue never shows the failure again.
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
service.forgetFiles(List.of("f-1"));
assertThat(service.list(null, null, 10)).isEmpty();
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
assertThat(service.list(null, null, 10))
.singleElement()
.extracting(FileRunEvent::status)
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void aRecurrenceLeavesAReviewersDismissalAlone() {
// Dismiss is a decision about the incident, not a claim about the document, so it
// outlasts a repeat where FILE_REMOVED and RESOLVED do not.
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
FileRunEvent event = service.list(null, null, 10).getFirst();
service.dispatch(event.id(), "DISMISS", Map.of());
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
assertThat(service.list(null, null, 10)).isEmpty();
}
}
@Nested
@DisplayName("triage never touches the document")
class NeverTouchesTheDocument {
@@ -352,13 +449,17 @@ class FileRunEventServiceTest {
}
@Test
void theOwnerIsOfferedTheirDocumentAndNotTheReviewersView() {
// The document is theirs to open; the processor view is for whoever reviews the team.
void theOwnerIsOfferedTheFixAndNotTheReviewersView() {
// The unlock is the owner's to do; the processor view is for whoever reviews.
when(authority.canEditPolicies()).thenReturn(false);
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(offeredFor(mine))
.containsExactly(FailureActionId.VIEW_FILE, FailureActionId.DISMISS);
.containsExactly(
FailureActionId.DECRYPT,
FailureActionId.VIEW_FILE,
FailureActionId.OPEN_IN_TOOL,
FailureActionId.DISMISS);
assertThat(service.availableActions(mine))
.allMatch(FileRunEventService.AvailableAction::enabled);
}
@@ -385,8 +486,10 @@ class FileRunEventServiceTest {
assertThat(offeredFor(unattended))
.containsExactly(
FailureActionId.DECRYPT,
FailureActionId.VIEW_FILE,
FailureActionId.VIEW_IN_PROCESSOR,
FailureActionId.OPEN_IN_TOOL,
FailureActionId.DISMISS);
}
@@ -499,6 +602,17 @@ class FileRunEventServiceTest {
.equals(action.disabledReasonKey()));
}
@Test
void carriesTheKindsPlacementIntentForEachOffer() {
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(service.availableActions(mine))
.filteredOn(action -> action.id() == FailureActionId.DECRYPT)
.singleElement()
.extracting(FileRunEventService.AvailableAction::slot)
.isEqualTo(FailureActionSlot.RESOLUTION);
}
@Test
void carriesTheLabelKeyForEachOffer() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
@@ -96,7 +96,9 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
@Override
public int reopenIfResolved(String id) {
FileRunEventEntity entity = rows.get(id);
if (entity == null || entity.getStatus() != FileRunEventStatus.RESOLVED) {
if (entity == null
|| (entity.getStatus() != FileRunEventStatus.RESOLVED
&& entity.getStatus() != FileRunEventStatus.FILE_REMOVED)) {
return 0;
}
entity.setStatus(FileRunEventStatus.NEW);
@@ -2,6 +2,7 @@ package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.util.List;
@@ -120,6 +121,32 @@ class NotificationProjectionTest {
.allMatch(action -> action.execution() == FailureActionId.Execution.CLIENT);
}
@Test
void holdsBackAFailureNamingNoDocumentBecauseTheBellCouldOnlySaySo() {
// The only row the bell can offer nothing for. The review surface still lists it.
given(FailureKind.UNKNOWN, ACTOR, null);
given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
assertThat(controller.list(null).notifications())
.singleElement()
.satisfies(row -> assertThat(row.fileId()).isEqualTo("f-1"));
}
@Test
void keepsARunScopedFailureThatStillNamesADocument() {
// An editor-reported tool failure is RUN-scoped but names the file it ran on, so
// filtering on the kind's scope rather than the row would have dropped it.
given(FailureKind.UNKNOWN, ACTOR, "f-2");
assertThat(controller.list(null).notifications())
.singleElement()
.satisfies(
row -> {
assertThat(row.kindId()).isEqualTo("UNKNOWN");
assertThat(row.fileId()).isEqualTo("f-2");
});
}
@Test
void namesTheSourceThatFedAnUnattendedRunSoItsFileIdIsNotMistakenForAClientsOwn() {
// Without the source a client looks up a hash it can never resolve and calls it
@@ -162,7 +189,53 @@ class NotificationProjectionTest {
assertThat(action.labelKey()).startsWith("portal.failures.action.");
assertThat(action.defaultLabel()).isNotBlank();
assertThat(action.execution()).isNotNull();
assertThat(action.slot()).isNotNull();
});
}
}
@Nested
@DisplayName("the response says whether the caller reviews the team")
class ReviewerFlag {
@Test
void trueForAReviewerSoTheClientFiltersNothing() {
when(authority.canEditPolicies()).thenReturn(true);
assertThat(controller.list(null).viewerReviewsTeam()).isTrue();
}
@Test
void falseForAMemberSoTheClientHidesRowsForFilesItDoesNotHold() {
when(authority.canEditPolicies()).thenReturn(false);
assertThat(controller.list(null).viewerReviewsTeam()).isFalse();
}
}
@Nested
@DisplayName("the response names the viewer, opaquely, for a client to scope read state on")
class ViewerKey {
@Test
void steadyForOneViewerAcrossReads() {
assertThat(controller.list(null).viewerKey())
.isEqualTo(controller.list(null).viewerKey())
.isNotBlank();
}
@Test
void differentForAnotherViewerSoOneCannotInheritTheOthersMarker() {
String mine = controller.list(null).viewerKey();
when(userService.getCurrentUsername()).thenReturn("someone.else@example.com");
assertThat(controller.list(null).viewerKey()).isNotEqualTo(mine);
}
@Test
void neverTheUsernameItself() {
// It lands in that browser's storage, and a client only needs to tell viewers apart.
assertThat(controller.list(null).viewerKey()).doesNotContain(ACTOR);
}
}
}
@@ -0,0 +1,152 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.notification.NotificationController;
import stirling.software.proprietary.notification.NotificationService;
import stirling.software.proprietary.notification.NotificationView;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/** Reporting a client-side retry that worked: the bell's one write. */
@ExtendWith(MockitoExtension.class)
@DisplayName("reporting a client-side retry that worked")
class NotificationResolveTest {
private static final Long TEAM = 7L;
private static final String ACTOR = "reviewer@example.com";
@Mock private PolicyManagementAuthority authority;
@Mock private UserServiceInterface userService;
private FileRunEventStore store;
private FileRunEventService failures;
private NotificationController controller;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
failures =
new FileRunEventService(
store,
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store))),
authority,
userService,
props);
controller = new NotificationController(new NotificationService(failures));
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
lenient().when(authority.canEditPolicies()).thenReturn(true);
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
}
private FileRunEvent given(FailureKind kind, String actor, String fileId) {
return store.record(RecordFailure.forEditor(kind, TEAM, actor, fileId, "boom"));
}
/** The status a refused call came back with. Fails the test if the call was allowed. */
private HttpStatus statusOf(Runnable call) {
try {
call.run();
} catch (ResponseStatusException e) {
return HttpStatus.valueOf(e.getStatusCode().value());
}
throw new AssertionError("expected the call to be refused");
}
@Test
void closesTheRowBehindThePrefixedId() {
// Why the route exists: the bell has no raw id to close its own row with.
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
NotificationView resolved = controller.resolved("failure:" + event.id());
assertThat(resolved.status()).isEqualTo(FileRunEventStatus.RESOLVED);
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.RESOLVED);
}
@Test
void theRowsOwnIdIsNotANotificationId() {
// Refused outright rather than left to work by accident for whichever source it reaches.
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
assertThat(statusOf(() -> controller.resolved(event.id())))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void anUnknownSourcePrefixIsABadRequest() {
// Not a 404: it was never a notification id, so there is no row to report missing.
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
assertThat(statusOf(() -> controller.resolved("quota:" + event.id())))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(statusOf(() -> controller.resolved("failure:")))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void reportingTheSameSuccessTwiceIsNotARefusal() {
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
NotificationView first = controller.resolved("failure:" + event.id());
assertThat(controller.resolved("failure:" + event.id()))
.isEqualTo(first)
.extracting(NotificationView::status)
.isEqualTo(FileRunEventStatus.RESOLVED);
}
@Test
void aRowAReviewerHasDismissedIsAConflict() {
// Their decision stands: a retry reporting in afterwards does not overwrite it.
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
failures.dispatch(event.id(), "DISMISS", Map.of());
assertThat(statusOf(() -> controller.resolved("failure:" + event.id())))
.isEqualTo(HttpStatus.CONFLICT);
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
@Test
void aColleaguesNotificationIsNotFoundForAMember() {
FileRunEvent theirs = given(FailureKind.UNKNOWN, "colleague@example.com", "f-1");
when(authority.canEditPolicies()).thenReturn(false);
assertThat(statusOf(() -> controller.resolved("failure:" + theirs.id())))
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void aReviewerClosesAColleaguesRowTheyFixed() {
// Visibility decides, not ownership: a reviewer reads the team's incidents, so a reviewer
// who fixes one closes it. The member's own row is unreachable to them the other way round.
FileRunEvent theirs = given(FailureKind.UNKNOWN, "colleague@example.com", "f-1");
controller.resolved("failure:" + theirs.id());
assertThat(store.find(theirs.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.RESOLVED);
}
}
@@ -324,8 +324,6 @@ class ConnectRequestServiceTest {
assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
}
// ---------------------------------------------------------------------------------------
private static ConnectRequest pending() {
ConnectRequest row = new ConnectRequest();
row.setRequestId("req");