Merge remote-tracking branch 'origin/main' into sweep/pr6500

This commit is contained in:
Anthony Stirling
2026-08-26 07:13:41 +01:00
175 changed files with 16280 additions and 997 deletions
+2
View File
@@ -176,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg
*.nar
*.ear
*.zip
# Real backend archives the form-bundle reader is tested against.
!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip
*.tar.gz
*.rar
*.db
@@ -62,6 +62,15 @@ public class FormFieldWithCoordinates {
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
@Schema(description = "Maximum character count for a text field (/MaxLen); null when unset")
private Integer maxLength;
@Schema(
description =
"Push button activation action as a spec string:"
+ " 'reset', 'print', 'uri:<url>' or 'submit:<url>'")
private String buttonActionSpec;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
@@ -94,5 +103,12 @@ public class FormFieldWithCoordinates {
@Schema(description = "Font size in PDF points")
private Float fontSize;
@Schema(
description =
"CropBox height in PDF points. Lets the frontend reverse the backend's"
+ " Y-flip when sending new widget coordinates back for"
+ " create/modify operations.")
private Float cropBoxHeight;
}
}
@@ -3,14 +3,20 @@ package stirling.software.common.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
@@ -59,6 +65,24 @@ public enum FormFieldTypeSupport {
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
if (definition.fontSize() != null && definition.fontSize() > 0) {
textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g");
}
if (Boolean.TRUE.equals(definition.multiline())) {
textField.setMultiline(true);
}
// Comb field: evenly spaced character cells (e.g. SSN, phone). Requires
// a positive MaxLen and is mutually exclusive with multiline.
if (definition.maxLength() != null && definition.maxLength() > 0) {
textField.setMaxLen(definition.maxLength());
if (!Boolean.TRUE.equals(definition.multiline())) {
try {
textField.setComb(true);
} catch (Exception e) {
log.debug("Unable to set comb flag: {}", e.getMessage());
}
}
}
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
@@ -272,14 +296,108 @@ public enum FormFieldTypeSupport {
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
// Empty signature placeholder: no value to apply (signed later by a sign tool).
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
if (field.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = field.getWidgets().get(0);
// Visible caption (/MK /CA).
String caption = definition.label();
if (caption == null || caption.isBlank()) {
caption = definition.name();
}
if (caption != null && !caption.isBlank()) {
PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics();
if (mk == null) {
mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(mk);
}
mk.setNormalCaption(caption);
}
widget.setPrinted(true);
applyButtonAction(widget, definition.buttonAction());
}
};
/**
* Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec,
* returning why it could not, or null on success. A blank spec clears the action.
*/
public static String applyButtonAction(PDAnnotationWidget widget, String action) {
if (action == null) {
return null;
}
if (action.isBlank()) {
// An explicit blank clears the action rather than leaving the old one behind.
widget.getCOSObject().removeItem(COSName.A);
return null;
}
String spec = action.trim();
if (!ACTION_SPEC.matcher(spec).matches()) {
return "'" + action + "' is not a button action this editor understands";
}
// The editor emits "uri:" the moment that kind is picked, before a URL is typed; an
// empty target is not yet an action, so clear rather than write an inert one.
int colon = spec.indexOf(':');
if (colon >= 0 && spec.substring(colon + 1).isBlank()) {
widget.getCOSObject().removeItem(COSName.A);
return null;
}
try {
String lower = spec.toLowerCase(Locale.ROOT);
if (lower.equals("reset")) {
widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject());
} else if (lower.equals("print")) {
PDActionNamed named = new PDActionNamed();
named.setN("Print");
widget.getCOSObject().setItem(COSName.A, named.getCOSObject());
} else if (lower.startsWith("uri:")) {
PDActionURI uri = new PDActionURI();
uri.setURI(spec.substring(4));
widget.getCOSObject().setItem(COSName.A, uri.getCOSObject());
} else if (lower.startsWith("submit:")) {
PDActionSubmitForm submit = new PDActionSubmitForm();
// Store the target URL on the action dictionary's /F entry.
submit.getCOSObject().setString(COSName.F, spec.substring(7));
widget.getCOSObject().setItem(COSName.A, submit.getCOSObject());
}
return null;
} catch (Exception e) {
log.debug("Unable to apply button action '{}': {}", action, e.getMessage());
return e.getMessage();
}
}
/** The spec forms applyButtonAction understands; anything else is reported, not dropped. */
private static final Pattern ACTION_SPEC =
Pattern.compile(
"^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSObjectKey;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** Pins how a choice field's options survive a save, which real forms rely on. */
class ChoiceOptionRoundTripTest {
private static PDComboBox combo(PDDocument document, List<String> options) throws IOException {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDComboBox field = new PDComboBox(form);
field.setPartialName("state");
field.setOptions(options);
form.getFields().add(field);
return field;
}
@Test
@DisplayName("a whitespace-only option survives a load, save and reload")
void whitespaceOptionSurvivesRoundTrip() throws IOException {
List<String> options = List.of(" ", "Alabama", "Alaska");
byte[] first;
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
combo(document, options);
document.save(out);
first = out.toByteArray();
}
// The real path edits a document loaded from bytes, not one built in memory.
byte[] saved;
try (PDDocument loaded = Loader.loadPDF(first);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
loaded.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDComboBox reread =
(PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state");
assertEquals(
options,
reread.getOptionsExportValues(),
"an option must not vanish because the writer made it indirect");
}
}
@Test
@DisplayName("an option stored as an indirect reference is still reported")
void indirectOptionIsStillReported() throws IOException {
try (PDDocument document = new PDDocument()) {
PDComboBox field = combo(document, List.of(" ", "Alabama"));
// Real forms reference option strings indirectly; the reader must follow the reference.
COSArray options = new COSArray();
options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0)));
options.add(new COSString("Alabama"));
field.getCOSObject().setItem(COSName.OPT, options);
// Every read path runs this repair first, which is where the reference is followed.
FormUtils.repairMissingWidgetPageReferences(document);
assertEquals(
List.of(" ", "Alabama"),
field.getOptionsExportValues(),
"an indirectly stored option must not be dropped");
}
}
@Test
@DisplayName("a signature field reports no value rather than a JVM identity hash")
void signatureValueIsNotAnIdentityHash() throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDSignatureField signature = new PDSignatureField(form);
signature.setPartialName("approval");
// Only a field that actually holds a signature hits getValueAsString's toString().
signature.setValue(new PDSignature());
form.getFields().add(signature);
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
FormUtils.FormFieldInfo field =
fields.stream()
.filter(f -> "approval".equals(f.name()))
.findFirst()
.orElseThrow();
// An identity hash differs per load, so the same document would describe itself twice.
assertNull(field.value(), "a signature has no text value");
}
}
}
@@ -0,0 +1,62 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */
class DeepFieldTreeTest {
private static byte[] chainOfKids(int depth) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
COSDictionary root = new COSDictionary();
root.setString(COSName.T, "n0");
COSDictionary cursor = root;
for (int i = 1; i < depth; i++) {
COSDictionary kid = new COSDictionary();
kid.setString(COSName.T, "n" + i);
kid.setItem(COSName.PARENT, cursor);
COSArray kids = new COSArray();
kids.add(kid);
cursor.setItem(COSName.KIDS, kids);
cursor = kid;
}
cursor.setItem(COSName.FT, COSName.getPDFName("Tx"));
COSArray fields = new COSArray();
fields.add(root);
form.getCOSObject().setItem(COSName.FIELDS, fields);
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("a deeply nested field tree extracts without overflowing the stack")
void deepKidsChainDoesNotOverflow() throws IOException {
// 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in
// the writer, not in extraction, so it is not something a read endpoint would hit.
byte[] pdf = chainOfKids(2000);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document));
}
}
}
@@ -0,0 +1,201 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.FormFieldWithCoordinates;
/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */
class FormEditSafetyTest {
private static PDDocument formWith(String name, String type) throws IOException {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.A4));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
50f,
700f,
200f,
20f,
null,
null,
type.equals("radio") ? List.of("a", "b") : null,
null,
null,
null,
null,
null,
null,
null)));
return document;
}
private static FormUtils.ModifyFormFieldDefinition modify(
String target, String type, Float width, Float height) {
// Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest.
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, type, null, null, null, width, height, null, null, null, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field")
void unrebuildableTypeIsRefused() throws IOException {
try (PDDocument document = formWith("choice", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("choice", "radio", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice");
assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller");
assertFalse(
field instanceof PDRadioButton,
"it could not become a radio, so it must not claim to be one");
assertEquals(
"text",
FormUtils.extractFormFields(document).getFirst().type(),
"the original field must survive untouched rather than be retyped");
}
}
@Test
@DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked")
void rebuiltCheckboxIsUsable() throws IOException {
try (PDDocument document = formWith("agree", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("agree", "checkbox", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree");
assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox");
assertNotNull(
field.getWidgets().getFirst().getAppearance(),
"without an appearance the checkbox renders blank and cannot be ticked");
}
}
@Test
@DisplayName("a size of zero or infinity is refused rather than written into the page")
void unusableSizeIsRefused() throws IOException {
for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) {
try (PDDocument document = formWith("box", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("box", null, bad, 20f)), skipped);
PDRectangle rect =
document.getDocumentCatalog()
.getAcroForm(null)
.getField("box")
.getWidgets()
.getFirst()
.getRectangle();
assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad);
assertEquals(
200f,
rect.getWidth(),
0.01f,
"the original size must survive: width " + bad);
}
}
}
@Test
@DisplayName("a widget off the page still reports its geometry instead of dropping the field")
void offPageWidgetKeepsItsGeometry() throws IOException {
try (PDDocument document = formWith("stray", "text")) {
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray");
// Above the page top: legal PDF, and the user needs the coordinates to drag it back.
field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(document);
FormFieldWithCoordinates stray =
fields.stream()
.filter(f -> "stray".equals(f.getName()))
.findFirst()
.orElseThrow();
assertNotNull(stray.getWidgets(), "the field must keep its widget list");
assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported");
assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay");
}
}
private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, null, value,
null, null, null, null, null, null);
}
private static FormUtils.ModifyFormFieldDefinition withOptions(
String target, List<String> options) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, options, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a value a radio group cannot hold does not destroy the group")
void badRadioValueLeavesTheGroupIntact() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withValue("plan", "not-an-option")), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
assertTrue(
field instanceof PDRadioButton,
"a rejected value must not turn the group into another kind of field");
assertEquals(
2,
field.getWidgets().size(),
"the group's options must survive a rejected value");
assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied");
}
}
@Test
@DisplayName("editing a radio group's options is either applied or reported, never ignored")
void radioOptionEditIsNotSilentlyDropped() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
boolean applied = field.getWidgets().size() == 3;
assertTrue(
applied || !skipped.isEmpty(),
"a change the UI shows as saved must either happen or be reported as skipped");
}
}
}
@@ -0,0 +1,61 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* A field name is caller-supplied and reaches several loggers. A line break in one would forge a
* second log line (CWE-117), so names carrying control characters are refused outright.
*/
class FormFieldNameSafetyTest {
@Test
void aNameWithCrLfIsRefused() {
String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4";
String reason = FormUtils.invalidFieldNameReason(forged);
assertNotNull(reason, "a name containing CR/LF must be refused");
assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break");
assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return");
}
@Test
void otherControlCharactersAreRefusedToo() {
assertNotNull(FormUtils.invalidFieldNameReason("tab\there"));
assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte"));
}
@Test
void ordinaryNamesStillPass() {
assertNull(FormUtils.invalidFieldNameReason("Full Name"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason("Mr Smith"));
}
@Test
void thePeriodRefusalDoesNotEchoControlCharacters() {
// Both problems at once: the period branch must not leak the raw name into a log line.
String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED");
assertNotNull(reason);
assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason);
}
@Test
void sanitizeForLogFlattensControlCharacters() {
assertEquals("a b", FormUtils.sanitizeForLog("a\nb"));
assertEquals("a b", FormUtils.sanitizeForLog("a\rb"));
assertEquals("plain", FormUtils.sanitizeForLog("plain"));
assertNull(FormUtils.sanitizeForLog(null));
}
@Test
void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason);
assertTrue(reason.contains("period"), "the message should name the problem: " + reason);
}
}
@@ -130,13 +130,15 @@ class FormFieldTypeSupportTest {
}
@Test
void doesNotSupportsDefinitionCreation_signatureReturnsTrue() {
assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_signatureReturnsFalse() {
// Signature placeholders are now creatable via the editor.
assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_buttonReturnsTrue() {
assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_buttonReturnsFalse() {
// Push buttons (with actions) are now creatable via the editor.
assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
}
@Test
@@ -0,0 +1,911 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Guards the form editor against silently destroying a field it edits. Assertions run after a
* save/reload cycle because only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditRegressionTest {
private static PDAcroForm setupForm(PDDocument document) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
/** Moves a field to a rect; null width/height leave the size alone. */
private static FormUtils.ModifyFormFieldDefinition moveTo(
String target, float x, float y, Float w, Float h) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null,
null, null, null);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
return field.getWidgets().get(0).getRectangle();
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(Collectors.toSet());
}
@Test
void movingCheckboxKeepsItFillable() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("agree");
assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox");
assertFalse(
((PDCheckBox) field).getOnValue().isEmpty(),
"a moved checkbox must keep an on-state, or it can never be ticked again");
assertTrue(
normalStateNames(field.getWidgets().get(0)).size() >= 2,
"both /AP /N states must survive a move");
PDRectangle rect = firstWidgetRect(acroForm, "agree");
assertEquals(200f, rect.getLowerLeftX(), 0.5f);
assertEquals(400f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree");
assertFalse(
checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state");
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt");
PDRectangle bbox =
widget.getAppearance()
.getNormalAppearance()
.getSubDictionary()
.get(COSName.getPDFName(checkBox.getOnValue()))
.getBBox();
assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size");
}
}
/** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */
@Test
void resizingCheckboxKeepsItChecked() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
((PDCheckBox) form.getField("agree")).check();
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
((PDCheckBox) acroForm.getField("agree")).isChecked(),
"a resize must not silently untick the box");
}
}
/** Only widgets.get(0) used to move, so a radio group lost every option but the first. */
@Test
void movingRadioGroupMovesEveryOption() throws IOException {
byte[] saved;
float[] before = new float[6];
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
assertEquals(3, widgets.size(), "the fixture needs three option widgets");
for (int i = 0; i < 3; i++) {
before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX();
before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY();
}
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group");
List<PDAnnotationWidget> widgets = field.getWidgets();
assertEquals(3, widgets.size(), "no option may be left behind");
float dx = 90f - before[0];
float dy = 670f - before[1];
for (int i = 0; i < 3; i++) {
PDRectangle rect = widgets.get(i).getRectangle();
assertEquals(
before[i * 2] + dx,
rect.getLowerLeftX(),
0.5f,
"option " + i + " should shift by the same delta");
assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f);
}
}
}
/** A signature's /AP is the signature, so it must never be dropped. */
@Test
void movingSignatureKeepsItsAppearance() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("signature", "sig", 50, 700, 120, 40, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"'sig' should still be a signature");
assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f);
}
}
@Test
void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason, "a period must be refused, not silently dropped");
assertTrue(reason.contains("period"), "the message should name the offending character");
assertNull(FormUtils.invalidFieldNameReason("Has Space"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason(null));
}
/** Dropped operations used to log a warning and still report success. */
@Test
void applyFieldEdits_reportsEveryDroppedOperation() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "present", 50, 700, 200, 20, null)));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)),
List.of(moveTo("ghost", 10f, 10f, null, null)),
List.of("alsoGhost"),
skipped);
assertEquals(3, skipped.size(), "each dropped operation should be reported");
assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation())));
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("present"),
"the rest of the document must still be applied");
}
}
/** A clean batch must not report anything, or the UI would cry wolf on every save. */
@Test
void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "fine", 50, 700, 200, 20, null)),
List.of(),
List.of(),
skipped);
assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips");
}
}
/** A drag must not normalise other options to the dragged widget's size. */
@Test
void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
// Hand-authored groups legitimately have option boxes of differing size.
PDRectangle second = widgets.get(1).getRectangle();
widgets.get(1)
.setRectangle(
new PDRectangle(
second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f));
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = acroForm.getField("choice").getWidgets();
assertEquals(
40f,
widgets.get(1).getRectangle().getWidth(),
0.5f,
"a pure drag must not shrink the other options");
assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */
@Test
void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) form.getField("agree");
// A NeedAppearances form exported by Word/LibreOffice looks exactly like this.
box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP);
box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On"));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertEquals(
"On",
box.getOnValue(),
"the export value must survive; inventing 'Yes' would orphan /V");
assertTrue(box.isChecked(), "the box was ticked and must stay ticked");
}
}
/** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */
@Test
void renameProblem_ignoresAnUnchangedQualifiedName() {
assertNull(
FormUtils.renameProblem("Customer.Name", "Customer.Name"),
"a field standing still must not be rejected for its parent's period");
assertNull(FormUtils.renameProblem("plain", null));
assertNotNull(
FormUtils.renameProblem("plain", "New.Name"),
"an actual rename introducing a period must still be refused");
}
/** A nested field whose name box was left at its qualified name must still be modified. */
@Test
void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
// Re-parent it so its qualified name legitimately contains a period.
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Name",
null,
null,
0,
90f,
600f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertTrue(
skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("Customer.Name");
assertNotNull(field, "the nested field must survive the edit");
assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */
@Test
void maxLengthZeroClearsTheCombSetting() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, 8, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen());
FormUtils.ModifyFormFieldDefinition clear =
new FormUtils.ModifyFormFieldDefinition(
"code", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, 0, null);
FormUtils.modifyFormFields(document, List.of(clear));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertEquals(
-1,
((PDTextField) acroForm.getField("code")).getMaxLen(),
"/MaxLen should be gone, not merely zero");
}
}
/** An unrecognised button action must be reported rather than silently ignored. */
@Test
void unknownButtonActionIsReported() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "an unusable action spec should be reported");
assertTrue(skipped.get(0).reason().contains("launchTheMissiles"));
}
}
/** Renaming a nested field must not re-parent it to the top level. */
@Test
void renamingNestedFieldKeepsItUnderItsParent() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Phone",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(rename), skipped);
assertTrue(
skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Phone"),
"the field should still live under Customer, not at the top level");
assertNull(acroForm.getField("Customer.Name"), "the old name should be gone");
}
}
/** One rejected action on a multi-widget button is one report, not one per widget. */
@Test
void unknownButtonActionIsReportedOncePerField() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDField button = form.getField("go");
// Give it a second widget, as a button repeated on two pages would have.
PDAnnotationWidget extra = new PDAnnotationWidget();
extra.setRectangle(new PDRectangle(50, 600, 100, 24));
extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject());
List<PDAnnotationWidget> widgets = new ArrayList<>(button.getWidgets());
widgets.add(extra);
button.getCOSObject()
.setItem(
COSName.KIDS,
new org.apache.pdfbox.cos.COSArray() {
{
for (PDAnnotationWidget w : widgets) add(w.getCOSObject());
}
});
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "one field, one report: " + skipped);
}
}
/** A clamped page index still creates the field, so it is not a dropped edit. */
@Test
void clampedPageIsNotReportedAsSkipped() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("late"),
"the field is created on the clamped page");
assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped);
}
}
/** Recreation builds a top-level field, so it must refuse rather than re-parent. */
@Test
void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(retype), skipped);
assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Name"),
"the original nested field must be left intact");
assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level");
}
}
/** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */
@Test
void incompleteUrlActionClearsRatherThanFailing() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition pickUri =
new FormUtils.ModifyFormFieldDefinition(
"go", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, "uri:");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(pickUri), skipped);
assertTrue(
skipped.isEmpty(),
"choosing a URL action before typing the URL is not an error: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"an empty target must leave no action behind");
}
}
/** A real URL still writes a real action. */
@Test
void completeUrlActionIsApplied() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition setUri =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"uri:https://example.com");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(setUri), skipped);
assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNotNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"the action should be written");
}
}
/** Builds a parent with the given terminal children already attached. */
private static PDNonTerminalField nest(
PDDocument document, PDAcroForm form, String parentName, String... childNames)
throws IOException {
List<FormUtils.NewFormFieldDefinition> defs = new ArrayList<>();
for (int i = 0; i < childNames.length; i++) {
defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null));
}
FormUtils.addNewFields(document, defs);
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName(parentName);
List<PDField> kids = new ArrayList<>();
for (String child : childNames) {
PDField field = form.getField(child);
field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(field);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
return parent;
}
/** A refused edit must not release the name the field still really has. */
@Test
void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name", "Email");
// Edit 1 is refused (type change on a nested field). Edit 2 then asks for the
// name edit 1 still occupies, which must not be handed out.
FormUtils.ModifyFormFieldDefinition refused =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Foo",
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Email",
"Customer.Name",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(refused, rename), skipped);
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName());
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"two fields must never share a qualified name: " + names);
assertTrue(
names.contains("Customer.Name"), "the refused field keeps its name: " + names);
}
}
/** A group name occupies the namespace, so a new field must not be able to take it. */
@Test
void groupNamesParticipateInCollisionChecks() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name");
FormUtils.addNewFields(
document, List.of(newField("text", "Customer", 50, 500, 100, 20, null)));
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
String fqn = f.getFullyQualifiedName();
if (fqn != null) names.add(fqn);
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"the new field must not take the group's name: " + names);
}
}
/** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */
@Test
void renameToBareParentPrefixIsRefused() {
assertNotNull(
FormUtils.renameProblem("Customer.Name", "Customer."),
"a name with nothing after the parent prefix is not a rename");
assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone"));
}
/** A type change must leave the field on its own page, not relocate it to the last one. */
@Test
void typeChangeKeepsTheFieldOnItsPage() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = new PDAcroForm(document);
for (int i = 0; i < 5; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
form.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(form);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"onPageTwo",
null,
"text",
1,
50f,
700f,
200f,
20f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"onPageTwo",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(retype));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("onPageTwo");
assertNotNull(field, "the retyped field should exist");
int page = -1;
for (int i = 0; i < reloaded.getNumberOfPages(); i++) {
for (var annot : reloaded.getPage(i).getAnnotations()) {
if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i;
}
}
assertEquals(
1, page, "a retyped field must stay on its own page, not move to the last");
}
}
}
@@ -0,0 +1,118 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.junit.jupiter.api.Test;
/** An edit the backend cannot honour must be reported, not logged and reported as success. */
class FormUtilsEditReportingTest {
private static FormUtils.NewFormFieldDefinition field(String type, String name) {
return new FormUtils.NewFormFieldDefinition(
name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null,
null, null, null);
}
private static PDDocument blank() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
@Test
void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = blank()) {
FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped);
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getFields().isEmpty(),
"an unsupported type must not quietly become a text field");
}
assertEquals(1, skipped.size(), "the caller must be told: " + skipped);
assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason());
}
@Test
void aLyingPageCountIsSurvivable() throws IOException {
// /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws.
byte[] broken =
("%PDF-1.4\n"
+ "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
+ "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n"
+ "trailer << /Root 1 0 R >>\n")
.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(broken)) {
// Must not throw; the field is reported as skipped instead.
FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped);
} catch (IOException loadFailure) {
// A parser that refuses the file outright is an equally acceptable outcome.
return;
}
assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown");
}
@Test
void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.LETTER));
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"agree",
"agree",
"checkbox",
0,
60f,
700f,
14f,
14f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)),
new ArrayList<>());
FormUtils.modifyFormFields(
document,
List.of(
new FormUtils.ModifyFormFieldDefinition(
"agree", null, null, null, 0, 200f, 400f, null, null, null,
null, null, null, null, null, null, null, null, null)));
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertNotNull(box);
assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable");
}
}
}
@@ -0,0 +1,467 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText;
import org.junit.jupiter.api.Test;
/**
* Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit
* {@code /Kids}, so only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditingTest {
private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) {
PDPage page = new PDPage(pageSize);
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newText(
String name, float x, float y, float w, float h) {
return new FormUtils.NewFormFieldDefinition(
name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null,
null, null);
}
private static FormUtils.NewFormFieldDefinition newField(
String type,
String name,
float x,
float y,
float w,
float h,
List<String> options,
Integer maxLength,
String buttonAction) {
return new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
x,
y,
w,
h,
null,
null,
options,
null,
null,
null,
null,
null,
maxLength,
buttonAction);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget");
return field.getWidgets().get(0).getRectangle();
}
/**
* PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an
* explicit appearance a created checkbox or radio renders blank and resolves to Off.
*/
@Test
void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("checkbox", "agree", 50, 600, 20, 20, null, null, null),
newField(
"radio",
"choice",
50,
500,
20,
20,
List.of("Yes", "No"),
null,
null),
newText("fullname", 50, 400, 200, 24)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm);
// NeedAppearances=false means viewers trust our streams, so they must exist.
assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run");
PDField checkBox = acroForm.getField("agree");
assertTrue(checkBox instanceof PDCheckBox);
assertEquals(
Set.of("Off", "Yes"),
normalStateNames(checkBox.getWidgets().get(0)),
"checkbox needs an Off and an on-state appearance");
PDField radio = acroForm.getField("choice");
assertTrue(radio instanceof PDRadioButton);
assertEquals(2, radio.getWidgets().size());
assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0)));
assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1)));
// A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for
// the whole form and every field above loses its appearance too.
PDField text = acroForm.getField("fullname");
assertNotNull(
text.getWidgets().get(0).getAppearance().getNormalAppearance(),
"text field should have a generated appearance");
}
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Test
void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "AcroForm should exist after reload");
assertTrue(acroForm.getField("created") instanceof PDTextField);
PDRectangle rect = firstWidgetRect(acroForm, "created");
assertNotNull(rect, "created widget should keep its rectangle after reload");
assertEquals(50f, rect.getLowerLeftX(), 0.5f);
assertEquals(700f, rect.getLowerLeftY(), 0.5f);
assertEquals(200f, rect.getWidth(), 0.5f);
assertEquals(20f, rect.getHeight(), 0.5f);
}
}
@Test
void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
// Shift the CropBox origin; the frontend sends CropBox-relative coords.
document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700));
FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "shifted");
// Absolute = CropBox-relative + CropBox lower-left offset.
assertEquals(15f, rect.getLowerLeftX(), 0.5f);
assertEquals(25f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.NewFormFieldDefinition def =
new FormUtils.NewFormFieldDefinition(
"opts",
null,
"text",
0,
10f,
10f,
120f,
18f,
null,
null,
null,
null,
null,
18f,
Boolean.TRUE,
Boolean.TRUE,
null,
null);
FormUtils.addNewFields(document, List.of(def));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("opts");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
assertTrue(field instanceof PDTextField);
assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("18"), "default appearance should carry the font size: " + da);
}
}
@Test
void modifyFormFields_movesAndResizesWidget() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null,
null, null, null, null, null, null, null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "movable");
assertEquals(100f, rect.getLowerLeftX(), 0.5f);
assertEquals(600f, rect.getLowerLeftY(), 0.5f);
assertEquals(150f, rect.getWidth(), 0.5f);
assertEquals(30f, rect.getHeight(), 0.5f);
}
}
@Test
void modifyFormFields_setsReadOnlyAndFontSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"editable",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
22f,
Boolean.TRUE,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("editable");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("22"), "font size should be reflected in DA: " + da);
}
}
@Test
void deleteFormFields_removesField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm acroForm = setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20)));
FormUtils.deleteFormFields(document, List.of("temp"));
// After delete the AcroForm may still exist; the field must be gone.
if (acroForm != null) {
assertNull(acroForm.getField("temp"));
}
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(acroForm == null || acroForm.getField("temp") == null);
}
}
@Test
void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertNotNull(field, "radio field should exist");
assertTrue(field instanceof PDRadioButton, "should be a radio button group");
assertEquals(2, field.getWidgets().size(), "one widget per option");
assertTrue(((PDRadioButton) field).getExportValues().contains("Yes"));
assertTrue(((PDRadioButton) field).getExportValues().contains("No"));
}
}
@Test
void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException {
// A radio group's label is its field name, not its first option, so the viewer label
// matches the name shown in the editor.
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"Choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
FormUtils.FormFieldInfo choice =
FormUtils.extractFormFields(reloaded).stream()
.filter(f -> "Choice".equals(f.name()))
.findFirst()
.orElse(null);
assertNotNull(choice, "radio field should be extracted");
assertEquals(
"Choice", choice.label(), "field name should win over the first option value");
}
}
@Test
void addNewFields_createsCombTextField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDTextField field = (PDTextField) acroForm.getField("ssn");
assertNotNull(field);
assertEquals(9, field.getMaxLen(), "comb max length should persist");
assertTrue(field.isComb(), "comb flag should be set");
}
}
@Test
void addNewFields_createsSignatureAndButton() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("signature", "sig", 50, 600, 200, 60, null, null, null),
newField("button", "btn", 50, 500, 120, 24, null, null, "reset")));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"signature placeholder should exist");
assertTrue(
acroForm.getField("btn") instanceof PDPushButton, "push button should exist");
}
}
@Test
void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20)));
FormUtils.applyFieldEdits(
document,
List.of(newText("fresh", 50, 600, 200, 20)),
List.of(),
List.of("old"));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm.getField("fresh"), "added field should be present");
assertNull(acroForm.getField("old"), "deleted field should be gone");
}
}
}
@@ -705,10 +705,20 @@ class FormUtilsGapTest {
"newName",
"New Label",
null, // keep type (text) -> in-place path
null,
null,
null,
null,
null,
Boolean.TRUE,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -731,7 +741,8 @@ class FormUtilsGapTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"missing", null, null, null, null, null, null, null, null);
"missing", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -754,7 +765,8 @@ class FormUtilsGapTest {
mods.add(null);
mods.add(
new FormUtils.ModifyFormFieldDefinition(
" ", null, null, null, null, null, null, null, null));
" ", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null));
FormUtils.modifyFormFields(doc, mods);
assertEquals(1, FormUtils.extractFormFields(doc).size());
@@ -285,13 +285,13 @@ class FormUtilsMoreTest {
}
@Test
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("offpage");
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
// returns null, which is still added to the per-field widget list.
// Off the page is legal PDF; dropping it would leave the user unable to drag it
// back.
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
List<FormFieldWithCoordinates> fields =
@@ -301,7 +301,8 @@ class FormUtilsMoreTest {
fields.get(0).getWidgets();
assertNotNull(widgets);
assertEquals(1, widgets.size());
assertNull(widgets.get(0));
assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay");
assertEquals(50f, widgets.get(0).getX(), 0.01f);
}
}
@@ -476,8 +477,18 @@ class FormUtilsMoreTest {
"combobox",
null,
null,
null,
null,
null,
null,
null,
List.of("One", "Two"),
"One",
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -505,10 +516,20 @@ class FormUtilsMoreTest {
null,
"listbox", // same type -> in-place path
null,
null,
null,
null,
null,
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
"Choose items",
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -529,7 +550,25 @@ class FormUtilsMoreTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
"keep",
null,
null,
"bogusType",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
@@ -554,7 +593,8 @@ class FormUtilsMoreTest {
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
"beta", "alpha", null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -575,7 +615,8 @@ class FormUtilsMoreTest {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
"x", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
@@ -0,0 +1,102 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Most real PDFs have no AcroForm at all, so adding the very first field has to build one that
* PDFBox will accept.
*/
class FormUtilsNoAcroFormTest {
private static final Path PLAIN_PDF =
Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf");
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float y, List<String> options, String defaultValue) {
// name, label, type, pageIndex, x, y, width, height, required, multiSelect,
// options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction
return new FormUtils.NewFormFieldDefinition(
name,
name,
type,
0,
60f,
y,
200f,
20f,
null,
null,
options,
defaultValue,
null,
null,
null,
null,
null,
null);
}
private static PDDocument loadPlain() throws IOException {
return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF));
}
@Test
void plainPdfReallyHasNoAcroForm() throws IOException {
try (PDDocument document = loadPlain()) {
assertNull(
document.getDocumentCatalog().getAcroForm(null),
"fixture must have no AcroForm or this test proves nothing");
}
}
@Test
void addsFirstFieldToAPdfWithNoAcroForm() throws IOException {
byte[] saved;
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = loadPlain()) {
FormUtils.addNewFields(
document,
List.of(
newField("text", "fullName", 700f, null, "Ada"),
newField("checkbox", "agree", 660f, null, null),
newField("radio", "contact", 600f, List.of("Email", "Post"), null)),
skipped);
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "an AcroForm should have been created");
assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text");
assertTrue(
acroForm.getDefaultAppearance() != null
&& !acroForm.getDefaultAppearance().isBlank(),
"/DA is required for variable text");
PDTextField text = (PDTextField) acroForm.getField("fullName");
assertNotNull(text, "the text field should exist");
assertEquals("Ada", text.getValueAsString());
assertNotNull(acroForm.getField("agree"));
assertNotNull(acroForm.getField("contact"));
}
}
}
@@ -0,0 +1,175 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
/**
* Option captions belong to the viewer, not the page. Drawing them into the content stream left
* orphan text behind on every move and delete, so these pin the page staying clean.
*/
class FormUtilsRadioCaptionTest {
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
return out.toByteArray();
}
private static PDDocument blankWithForm() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
private static String textOf(byte[] pdf) throws IOException {
try (PDDocument reloaded = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(reloaded);
}
}
@Test
void radioOptionsAreNotBakedIntoThePage() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
saved = save(document);
}
// The caption is the viewer's job; page content cannot follow a widget that moves.
String text = textOf(saved);
assertFalse(text.contains("Email"), "options must not be page content: " + text);
assertFalse(text.contains("Telephone"), "options must not be page content: " + text);
assertFalse(text.contains("Post"), "options must not be page content: " + text);
}
@Test
void captionsDoNotReplaceTheWidgetsThemselves() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L"))));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRadioButton radio = (PDRadioButton) acroForm.getField("size");
assertEquals(3, radio.getWidgets().size(), "one widget per option");
assertFalse(radio.getExportValues().isEmpty(), "export values must survive");
}
}
@Test
void aTextFieldDrawsNoStrayCaption() throws IOException {
// Control: proves the assertions above read the captions and not some unrelated content.
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document, List.of(newField("text", "fullName", 72, 600, 200, 18, null)));
saved = save(document);
}
assertTrue(textOf(saved).isBlank(), "a text field should add no page content");
}
@Test
void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException {
byte[] withRadio;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
withRadio = save(document);
}
assertFalse(
textOf(withRadio).contains("Telephone"),
"the group adds no page text to begin with");
byte[] afterDelete;
try (PDDocument document = Loader.loadPDF(withRadio)) {
FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact"));
afterDelete = save(document);
}
String text = textOf(afterDelete);
assertFalse(
text.contains("Telephone"),
"a deleted radio group must not leave its captions on the page: " + text);
}
@Test
void theDrawnBoxIsTheWholeGroupNotOneOption() {
// A 90pt box used to become a 360pt stack because each option got the full height.
PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, null, null);
assertEquals(3, rects.size());
float top = rects.get(0).getUpperRightY();
float bottom = rects.get(2).getLowerLeftY();
assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height");
assertEquals(
box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge");
for (PDRectangle r : rects) {
assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square");
assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box");
}
}
@Test
void explicitSizeAndGapWin() {
PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f);
for (PDRectangle r : rects) {
assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim");
}
float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY();
assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim");
}
@Test
void aSingleOptionStillFitsTheBox() {
var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null);
assertEquals(1, rects.size());
assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box");
}
}
@@ -0,0 +1,57 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A form with no default resources is ordinary; adding a field to it must still work. */
class MissingDefaultResourcesTest {
@Test
@DisplayName("a text field can be added to a form that has no default resources")
void addsToFormWithoutDefaultResources() throws IOException {
// A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all.
byte[] pdf;
try (PDDocument built = new PDDocument();
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
built.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(built);
// A /DA naming a font with no /DR to resolve it is what PDFBox refuses.
form.setDefaultAppearance("/Helv 0 Tf 0 g");
form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR);
built.getDocumentCatalog().setAcroForm(form);
built.save(out);
pdf = out.toByteArray();
}
try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertTrue(
skipped.isEmpty(),
"adding a plain text field should not be refused: " + skipped);
assertEquals(
1,
FormUtils.extractFormFields(document).size(),
"the field should be in the document");
}
}
}
@@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else {
@@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
}
@@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.poi.ss.usermodel.*;
@@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
public class FormFillController {
/** Carries the edits a request asked for but the document could not take, as base64 JSON. */
public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits";
/** How many were skipped in total, which may exceed the number listed in the header above. */
public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total";
/** Keeps the header well inside Jetty's response-header budget. */
private static final int MAX_REPORTED_SKIPS = 20;
/** Bytes of encoded header value, well under the container's limit for the whole header set. */
private static final int MAX_SKIP_HEADER_BYTES = 4096;
private static final int MAX_SKIP_FIELD_CHARS = 120;
/** Entry names inside the {@code ?includeFields=true} bundle. */
private static final String FIELDS_ENTRY = "fields.json";
private static final String DOCUMENT_ENTRY = "document.pdf";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@@ -68,6 +98,72 @@ public class FormFillController {
return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager);
}
/**
* Rejects field names PDFBox cannot store before the document is touched, so the caller gets a
* 400 naming the offending character instead of a 200 with the field quietly missing.
*/
private static void requireUsableFieldNames(
List<FormUtils.NewFormFieldDefinition> adds,
List<FormUtils.ModifyFormFieldDefinition> modifies) {
Stream<String> problems =
Stream.concat(
adds.stream()
.map(FormUtils.NewFormFieldDefinition::name)
.map(FormUtils::invalidFieldNameReason),
// A rename to the same name is not a rename, so a nested field whose
// qualified name already contains a period is left alone.
modifies.stream()
.map(m -> FormUtils.renameProblem(m.targetName(), m.name())));
problems.filter(Objects::nonNull)
.findFirst()
.ifPresent(
reason -> {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "{0}", reason);
});
}
/**
* The body is the updated PDF, so dropped edits travel as a base64 JSON header;
* percent-encoding would turn every space into a plus sign.
*/
private ResponseEntity<Resource> withSkippedEdits(
ResponseEntity<Resource> response, List<FormUtils.SkippedFieldEdit> skipped) {
if (skipped.isEmpty()) {
return response;
}
// A count cap alone is not enough: one very long field name can still overflow the
// header budget and turn the response into an error page, losing the edited PDF.
List<FormUtils.SkippedFieldEdit> reported = new ArrayList<>();
String encoded = "";
for (FormUtils.SkippedFieldEdit edit : skipped) {
if (reported.size() >= MAX_REPORTED_SKIPS) {
break;
}
reported.add(
new FormUtils.SkippedFieldEdit(
edit.operation(),
FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS),
FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS)));
String candidate =
Base64.getEncoder()
.encodeToString(
objectMapper
.writeValueAsString(reported)
.getBytes(StandardCharsets.UTF_8));
if (candidate.length() > MAX_SKIP_HEADER_BYTES) {
reported.removeLast();
break;
}
encoded = candidate;
}
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size()))
.header(SKIPPED_EDITS_HEADER, encoded)
.body(response.getBody());
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
@@ -257,6 +353,110 @@ public class FormFillController {
}
}
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Add new form fields",
description =
"Creates new form fields in the provided PDF and returns the updated file")
public ResponseEntity<Resource> addFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON array of new field definitions",
example =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
@RequestPart(value = "fields", required = false)
byte[] fieldsPayload)
throws IOException {
String rawFields = decodePart(fieldsPayload);
List<FormUtils.NewFormFieldDefinition> definitions =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
if (definitions.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"fields payload");
}
requireUsableFieldNames(definitions, List.of());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.addNewFields(document, definitions, skipped)),
skipped);
}
@PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Apply a batch of form field edits",
description =
"Adds, modifies, and deletes form fields in a single request (one document"
+ " load/save) and returns the updated file")
public ResponseEntity<Resource> editFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON object with optional 'add', 'modify' and 'delete'"
+ " sections",
example =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}],"
+ "\"modify\":[],\"delete\":[]}")
@RequestPart(value = "edits", required = false)
byte[] editsPayload,
@Parameter(
description =
"Return a ZIP holding the updated PDF plus the field list it"
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
@RequestParam(value = "includeFields", defaultValue = "false")
boolean includeFields)
throws IOException {
String rawEdits = decodePart(editsPayload);
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits);
if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one edit", "edits payload");
}
requireUsableFieldNames(batch.add(), batch.modify());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
includeFields,
document ->
FormUtils.applyFieldEdits(
document,
batch.add(),
batch.modify(),
batch.delete(),
skipped)),
skipped);
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -285,8 +485,15 @@ public class FormFillController {
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
requireUsableFieldNames(List.of(), modifications);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.modifyFormFields(document, modifications, skipped)),
skipped);
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -319,8 +526,13 @@ public class FormFillController {
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.deleteFormFields(document, names, skipped)),
skipped);
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -358,13 +570,81 @@ public class FormFillController {
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
return processSingleFile(file, suffix, false, processor);
}
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor)
throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
FormUtils.repairMissingWidgetPageReferences(document);
processor.accept(document);
return saveDocument(document, baseName);
return includeFields
? saveDocumentWithFields(document, baseName)
: saveDocument(document, baseName);
}
}
/**
* Answers "what fields does the saved file have?" from the document still open here, so the
* caller does not have to upload the result back to ask.
*/
private ResponseEntity<Resource> saveDocumentWithFields(PDDocument document, String baseName)
throws IOException {
TempFile zip = null;
boolean zipTransferred = false;
try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) {
document.save(pdf.getFile());
// Read the fields after the save so they describe the bytes actually being returned.
byte[] fields =
objectMapper.writeValueAsBytes(
FormUtils.extractFormFieldsWithCoordinates(document));
zip = tempFileManager.createManagedTempFile(".zip");
writeFieldBundle(zip.getPath(), pdf.getPath(), fields);
ResponseEntity<Resource> response =
WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip");
zipTransferred = true;
return response;
} finally {
if (zip != null && !zipTransferred) {
zip.close();
}
}
}
/**
* Deflates the JSON because it is text, but stores the PDF: its streams are already compressed,
* so deflating costs ~25ms per MB to save a few percent.
*/
private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields)
throws IOException {
long pdfSize = Files.size(pdfPath);
CRC32 crc = new CRC32();
try (InputStream in = Files.newInputStream(pdfPath)) {
byte[] buffer = new byte[8192];
for (int read; (read = in.read(buffer)) != -1; ) {
crc.update(buffer, 0, read);
}
}
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) {
ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY);
fieldsEntry.setMethod(ZipEntry.DEFLATED);
zip.putNextEntry(fieldsEntry);
zip.write(fields);
zip.closeEntry();
ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY);
documentEntry.setMethod(ZipEntry.STORED);
documentEntry.setSize(pdfSize);
documentEntry.setCompressedSize(pdfSize);
documentEntry.setCrc(crc.getValue());
zip.putNextEntry(documentEntry);
Files.copy(pdfPath, zip);
zip.closeEntry();
zip.finish();
}
}
@@ -28,6 +28,8 @@ final class FormPayloadParser {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
@@ -94,6 +96,43 @@ final class FormPayloadParser {
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
}
/**
* Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each
* section is optional. The delete section accepts the same shapes as {@link #parseNameList}.
*/
static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of());
}
final JsonNode root = objectMapper.readTree(json);
List<FormUtils.NewFormFieldDefinition> adds = List.of();
List<FormUtils.ModifyFormFieldDefinition> modifies = List.of();
List<String> deletes = List.of();
if (root != null && root.isObject()) {
final JsonNode addNode = root.get("add");
if (addNode != null && addNode.isArray()) {
adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE);
}
final JsonNode modifyNode = root.get("modify");
if (modifyNode != null && modifyNode.isArray()) {
modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE);
}
final JsonNode deleteNode = root.get("delete");
if (deleteNode != null && !deleteNode.isNull()) {
deletes = parseNameList(objectMapper, deleteNode.toString());
}
}
return new FormUtils.FieldEditBatch(adds, modifies, deletes);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
@@ -17,7 +17,7 @@
{
"moduleName": "ch.qos.logback:logback-classic",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleVersion": "1.6.3",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
@@ -31,7 +31,7 @@
{
"moduleName": "ch.qos.logback:logback-core",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleVersion": "1.6.3",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
@@ -1064,21 +1064,14 @@
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.46",
"moduleVersion": "2.2.47",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.47",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.46",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1090,9 +1083,9 @@
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.46",
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1103,6 +1096,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "jakarta.activation:jakarta.activation-api",
"moduleUrl": "https://www.eclipse.org",
@@ -2304,7 +2304,7 @@
},
{
"moduleName": "org.simplejavamail:core-module",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2317,13 +2317,13 @@
},
{
"moduleName": "org.simplejavamail:outlook-module",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:simple-java-mail",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2343,10 +2343,10 @@
},
{
"moduleName": "org.snakeyaml:snakeyaml-engine",
"moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "3.0.1",
"moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "3.1.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
@@ -0,0 +1,374 @@
package stirling.software.SPDF.controller.api.form;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays
* interchangeable with the follow-up request it exists to remove.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("edit-fields field bundle")
class FormFieldBundleTest {
/** Set to a directory to dump the produced archives for the frontend reader's fixtures. */
private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private FormFillController controller;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
invocation -> {
File file =
Files.createTempFile(
"bundle", invocation.<String>getArgument(0))
.toFile();
TempFile temp = mock(TempFile.class);
lenient().when(temp.getFile()).thenReturn(file);
lenient().when(temp.getPath()).thenReturn(file.toPath());
return temp;
});
objectMapper = JsonMapper.builder().build();
var field = FormFillController.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(controller, objectMapper);
}
// -- document shapes ----------------------------------------------
private record Style(
String name, int pages, int rotation, List<FormUtils.NewFormFieldDefinition> fields) {}
private static FormUtils.NewFormFieldDefinition field(
String name, String type, int page, float y, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null,
null, null, null, null);
}
static List<Style> styles() {
List<Style> styles = new ArrayList<>();
styles.add(new Style("text-only", 1, 0, List.of(field("fullName", "text", 0, 700f, null))));
styles.add(
new Style(
"checkbox-and-radio",
1,
0,
List.of(
field("agree", "checkbox", 0, 700f, null),
field("plan", "radio", 0, 650f, List.of("basic", "pro")))));
styles.add(
new Style(
"choice-widgets",
1,
0,
List.of(
field("country", "dropdown", 0, 700f, List.of("UK", "IE", "FR")),
field("tags", "listbox", 0, 640f, List.of("a", "b", "c")))));
styles.add(
new Style(
"signature", 1, 0, List.of(field("approval", "signature", 0, 700f, null))));
styles.add(
new Style(
"multi-page",
3,
0,
List.of(
field("p1", "text", 0, 700f, null),
field("p2", "text", 1, 700f, null),
field("p3", "text", 2, 700f, null))));
styles.add(new Style("rotated-90", 1, 90, List.of(field("rot", "text", 0, 700f, null))));
styles.add(new Style("rotated-270", 1, 270, List.of(field("rot", "text", 0, 700f, null))));
styles.add(
new Style(
"unicode-names",
1,
0,
List.of(
field("nom_complet", "text", 0, 700f, null),
field("adresse postale", "text", 0, 660f, null))));
List<FormUtils.NewFormFieldDefinition> many = new ArrayList<>();
for (int i = 0; i < 120; i++) {
many.add(
field(
"field_" + i,
i % 3 == 0 ? "checkbox" : "text",
i / 40,
740f - (i % 40) * 18f,
null));
}
styles.add(new Style("many-fields", 3, 0, many));
return styles;
}
private byte[] blankPdf(int pages, int rotation) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
page.setRotation(rotation);
document.addPage(page);
}
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
document.save(out);
return out.toByteArray();
}
}
// -- the test ------------------------------------------------------
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("bundled list matches the follow-up request it replaces")
void bundleMatchesRefetch(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
assertThat(bundle).containsKeys("document.pdf", "fields.json");
byte[] editedPdf = bundle.get("document.pdf");
assertThat(new String(editedPdf, 0, 5, StandardCharsets.UTF_8)).isEqualTo("%PDF-");
// The comparison that matters: ask the endpoint this feature stops re-calling,
// and demand a match.
MockMultipartFile saved =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
assertThat(new String(bundle.get("fields.json"), StandardCharsets.UTF_8))
.isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
dumpFixture(style.name(), zipBytes);
}
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("pdf entry is stored and json entry is deflated")
void perEntryCompression(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, Integer> methods = methodsOf(zipBytes);
assertThat(methods.get("document.pdf")).isEqualTo(ZipEntry.STORED);
assertThat(methods.get("fields.json")).isEqualTo(ZipEntry.DEFLATED);
}
@Test
@DisplayName("hierarchical field names survive the bundle")
void nestedFieldNames() throws Exception {
byte[] source = nestedPdf();
MockMultipartFile upload =
new MockMultipartFile("file", "nested.pdf", "application/pdf", source);
byte[] edits =
objectMapper.writeValueAsBytes(
Map.of(
"modify",
List.of(
Map.of(
"targetName",
"Customer.Name",
"defaultValue",
"Ada"))));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
byte[] editedPdf = bundle.get("document.pdf");
MockMultipartFile saved =
new MockMultipartFile("file", "nested.pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
String bundled = new String(bundle.get("fields.json"), StandardCharsets.UTF_8);
assertThat(bundled).contains("Customer.Name");
assertThat(bundled).isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
}
/** Builds a parent field with two children, which add-fields cannot express. */
private byte[] nestedPdf() throws IOException {
try (PDDocument document = Loader.loadPDF(blankPdf(1, 0));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
FormUtils.addNewFields(
document,
List.of(
field("Name", "text", 0, 700f, null),
field("Email", "text", 0, 660f, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
List<PDField> kids = new ArrayList<>();
for (String child : List.of("Name", "Email")) {
PDField kid = form.getField(child);
kid.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(kid);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("bundle stays close to the wire cost of the two calls it replaces")
void wireCost() throws Exception {
Style style =
styles().stream()
.filter(s -> s.name().equals("many-fields"))
.findFirst()
.orElseThrow();
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", "cost.pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
Map<String, byte[]> bundle;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
bundle = unzip(zipBytes);
int pdfSize = bundle.get("document.pdf").length;
int jsonSize = bundle.get("fields.json").length;
System.out.printf(
"wire: pdf=%d json=%d zip=%d overhead=%d bytes (%.2f%% over the pdf alone)%n",
pdfSize,
jsonSize,
zipBytes.length,
zipBytes.length - pdfSize,
100.0 * (zipBytes.length - pdfSize) / pdfSize);
// True for a field list this repetitive; on a tiny list the ~200 bytes of zip framing can
// exceed what deflate saves, so this is a property of the fixture, not of every document.
assertThat(zipBytes.length).isLessThan(pdfSize + jsonSize);
}
// -- helpers -------------------------------------------------------
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(out);
}
return out.toByteArray();
}
private static Map<String, byte[]> unzip(byte[] zipBytes) throws IOException {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
in.transferTo(out);
entries.put(entry.getName(), out.toByteArray());
}
}
return entries;
}
private static Map<String, Integer> methodsOf(byte[] zipBytes) throws IOException {
Map<String, Integer> methods = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
methods.put(entry.getName(), entry.getMethod());
in.transferTo(OutputStream.nullOutputStream());
}
}
return methods;
}
private static void dumpFixture(String name, byte[] zipBytes) throws IOException {
if (FIXTURE_DIR == null) {
return;
}
Path dir = Paths.get(FIXTURE_DIR);
Files.createDirectories(dir);
Files.write(dir.resolve(name + ".zip"), zipBytes);
}
}
@@ -29,6 +29,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -330,6 +331,160 @@ class FormFillControllerTest {
}
}
// ── addFields ──────────────────────────────────────────────────────
@Nested
@DisplayName("addFields")
class AddFields {
@Test
@DisplayName("throws when fields payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when fields payload is an empty list")
void emptyPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), "[]".getBytes()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a valid new-field payload")
void validPayload() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]";
ResponseEntity<Resource> response = controller.addFields(file, json.getBytes());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
}
// ── editFields (combined) ──────────────────────────────────────────
@Nested
@DisplayName("editFields")
class EditFields {
@Test
@DisplayName("throws when edits payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.editFields(pdfFile(), null, false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when all sections are empty")
void emptyBatch() {
assertThatThrownBy(
() ->
controller.editFields(
pdfFile(),
"{\"add\":[],\"modify\":[],\"delete\":[]}".getBytes(),
false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a combined add/delete batch")
void validBatch() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}],\"modify\":[],"
+ "\"delete\":[]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("refuses a field name containing a period before touching the document")
void refusesPeriodInName() throws Exception {
String json =
"{\"add\":[{\"name\":\"Customer.Name\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}";
assertThatThrownBy(() -> controller.editFields(pdfFile(), json.getBytes(), false))
.hasMessageContaining("period");
// Rejected up front, so the document is never even loaded.
verify(pdfDocumentFactory, never()).load(any(MockMultipartFile.class));
}
@Test
@DisplayName("renaming a nested field to its own qualified name is not a rename")
void allowsUnchangedQualifiedName() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"modify\":[{\"targetName\":\"Customer.Name\",\"name\":\"Customer.Name\","
+ "\"x\":10,\"y\":10}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// It must get past validation into the edit loop: the only complaint should be that
// this document has no such field, never that the name contains a period.
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("no field with that name exists").doesNotContain("period");
}
@Test
@DisplayName("reports a dropped edit as base64 JSON in the skipped-edits header")
void reportsSkippedEdits() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json = "{\"delete\":[\"noSuchField\"]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("noSuchField").contains("delete");
// Base64 rather than percent-encoding, so spaces survive as spaces.
assertThat(report).contains("no field with that name exists");
}
@Test
@DisplayName("omits the skipped-edits header when everything applied")
void noHeaderOnCleanBatch() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"add\":[{\"name\":\"clean\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER))
.isNull();
}
}
// ── buildBaseName ──────────────────────────────────────────────────
@Nested
@@ -384,4 +539,156 @@ class FormFillControllerTest {
assertThat(result).isEqualTo("document_filled");
}
}
// -- includeFields bundle ------------------------------------------
@Nested
@DisplayName("editFields ?includeFields=true")
class FieldBundle {
private byte[] editsPayload() {
return ("{\"add\":[{\"name\":\"bundled\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private java.util.Map<String, java.util.zip.ZipEntry> entriesOf(byte[] zipBytes)
throws IOException {
java.util.Map<String, java.util.zip.ZipEntry> found = new java.util.HashMap<>();
try (java.util.zip.ZipInputStream in =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(zipBytes))) {
for (java.util.zip.ZipEntry e; (e = in.getNextEntry()) != null; ) {
java.io.ByteArrayOutputStream data = new java.io.ByteArrayOutputStream();
in.transferTo(data);
// getMethod/getSize are only final once the entry has been fully read.
found.put(e.getName(), e);
payloads.put(e.getName(), data.toByteArray());
}
}
return found;
}
private final java.util.Map<String, byte[]> payloads = new java.util.HashMap<>();
private byte[] bundleFor(MockMultipartFile file) throws Exception {
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
ResponseEntity<Resource> response = controller.editFields(file, editsPayload(), true);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
return drainBody(response);
}
@Test
@DisplayName("returns a zip holding the pdf and the field list")
void bundlesBoth() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
assertThat(payloads).containsKeys("document.pdf", "fields.json");
assertThat(new String(payloads.get("document.pdf"), 0, 5)).isEqualTo("%PDF-");
assertThat(
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8))
.contains("bundled");
}
@Test
@DisplayName("stores the pdf entry but deflates the json")
void perEntryMethods() throws Exception {
byte[] zip = bundleFor(pdfFile());
java.util.Map<String, java.util.zip.ZipEntry> entries = entriesOf(zip);
assertThat(entries.get("document.pdf").getMethod())
.as("deflating an already-compressed PDF burns CPU for almost nothing")
.isEqualTo(java.util.zip.ZipEntry.STORED);
assertThat(entries.get("fields.json").getMethod())
.as("the JSON is text and no longer gets the container's gzip")
.isEqualTo(java.util.zip.ZipEntry.DEFLATED);
}
@Test
@DisplayName("bundled fields match what a follow-up fetch would have returned")
void matchesTheSecondCallItReplaces() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
byte[] bundledPdf = payloads.get("document.pdf");
// Re-ask the endpoint this feature stops re-calling, using the returned bytes.
MockMultipartFile saved =
new MockMultipartFile("file", "test.pdf", "application/pdf", bundledPdf);
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(bundledPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<
java.util.List<
stirling.software.common.model.FormFieldWithCoordinates>>
refetched = controller.listFieldsWithCoordinates(saved);
String viaRefetch = realObjectMapper.writeValueAsString(refetched.getBody());
String viaBundle =
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(viaBundle)
.as("the bundle must be interchangeable with the round trip it removes")
.isEqualTo(viaRefetch);
}
}
@Test
@DisplayName("omitting the flag still returns a bare pdf")
void defaultsToPlainPdf() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
byte[] body = drainBody(controller.editFields(file, editsPayload(), false));
assertThat(new String(body, 0, 5)).isEqualTo("%PDF-");
}
}
// -- skipped-edits header budget -----------------------------------
@Nested
@DisplayName("skipped-edits header")
class SkipHeaderBudget {
@Test
@DisplayName("stays within budget however long the reported names are")
void staysWithinBudget() throws Exception {
java.util.List<FormUtils.SkippedFieldEdit> skipped = new java.util.ArrayList<>();
String huge = "x".repeat(20000);
for (int i = 0; i < 40; i++) {
skipped.add(new FormUtils.SkippedFieldEdit("modify", huge, huge));
}
var method =
FormFillController.class.getDeclaredMethod(
"withSkippedEdits", ResponseEntity.class, java.util.List.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> response =
(ResponseEntity<Resource>)
method.invoke(controller, streamingOk(new byte[] {1}), skipped);
String header = response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(header).isNotNull();
// Not merely short: an empty header would pass a length check while telling the
// user nothing, because the alert renders only when it has entries.
String decoded =
new String(
java.util.Base64.getDecoder().decode(header),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(decoded).startsWith("[{");
assertThat(decoded).contains("...");
// Overflowing the container's header budget turns the reply into an error page,
// which loses the edited PDF the user just saved.
assertThat(header.length()).isLessThanOrEqualTo(4096);
assertThat(
response.getHeaders()
.getFirst(FormFillController.SKIPPED_EDITS_TOTAL_HEADER))
.isEqualTo("40");
}
}
}
@@ -170,6 +170,94 @@ class FormPayloadParserTest {
}
}
// ── parseNewFieldDefinitions ───────────────────────────────────────
@Nested
@DisplayName("parseNewFieldDefinitions")
class ParseNewFieldDefinitions {
@Test
@DisplayName("returns empty list for null input")
void nullInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, null);
assertThat(result).isEmpty();
}
@Test
@DisplayName("returns empty list for blank input")
void blankInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, " ");
assertThat(result).isEmpty();
}
@Test
@DisplayName("parses a valid new-field list including geometry and flags")
void validNewFields() {
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20,"
+ "\"fontSize\":14,\"readOnly\":true,\"multiline\":true}]";
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, json);
assertThat(result).hasSize(1);
FormUtils.NewFormFieldDefinition def = result.get(0);
assertThat(def.name()).isEqualTo("NewField");
assertThat(def.type()).isEqualTo("text");
assertThat(def.pageIndex()).isEqualTo(0);
assertThat(def.x()).isEqualTo(50f);
assertThat(def.y()).isEqualTo(700f);
assertThat(def.width()).isEqualTo(200f);
assertThat(def.height()).isEqualTo(20f);
assertThat(def.fontSize()).isEqualTo(14f);
assertThat(def.readOnly()).isTrue();
assertThat(def.multiline()).isTrue();
}
}
// ── parseFieldEdits ────────────────────────────────────────────────
@Nested
@DisplayName("parseFieldEdits")
class ParseFieldEdits {
@Test
@DisplayName("returns empty batch for null input")
void nullInput() {
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, null);
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).isEmpty();
}
@Test
@DisplayName("parses a combined add/modify/delete batch")
void combinedBatch() {
String json =
"{\"add\":[{\"name\":\"new1\",\"type\":\"text\",\"pageIndex\":0,\"x\":1,"
+ "\"y\":2,\"width\":3,\"height\":4}],"
+ "\"modify\":[{\"targetName\":\"old1\",\"label\":\"L\"}],"
+ "\"delete\":[\"gone1\",{\"name\":\"gone2\"}]}";
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, json);
assertThat(batch.add()).hasSize(1);
assertThat(batch.add().get(0).name()).isEqualTo("new1");
assertThat(batch.modify()).hasSize(1);
assertThat(batch.modify().get(0).targetName()).isEqualTo("old1");
assertThat(batch.delete()).containsExactly("gone1", "gone2");
}
@Test
@DisplayName("tolerates missing sections")
void missingSections() {
FormUtils.FieldEditBatch batch =
FormPayloadParser.parseFieldEdits(objectMapper, "{\"delete\":[\"x\"]}");
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).containsExactly("x");
}
}
// ── parseNameList ──────────────────────────────────────────────────
@Nested
@@ -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.
*
* <p>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;
};
}
}
@@ -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;
}
}
@@ -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.
*
* <p>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<FailureAction> 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<String> 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();
@@ -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
}
@@ -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.
*
* <p>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.
*
* <p>A kind offers an acknowledgement only where there is something to acknowledge <em>doing</em>.
* With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the
* one that clears it.
* <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.
*/
@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<OfferedAction> 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);
@@ -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<FileRunEventView> 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<String, String> inputs) {
Map<String, String> safeInputs() {
public Map<String, String> safeInputs() {
return inputs == null ? Map.of() : inputs;
}
}
@@ -98,20 +98,18 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
* Close the incidents about documents their owner deleted from the editor: the queue is what
* needs attention, and a document that no longer exists needs none.
*
* <p>Restricted 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.
* <p>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,
@@ -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()}.
*
* <p>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<String, String> 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<AvailableAction> 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) {}
}
@@ -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());
}
@@ -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
}
@@ -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<NotificationView> notifications) {}
}
@@ -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<NotificationView> 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());
}
}
@@ -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;
}
}
@@ -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<FileRunEventView.ActionView> actions) {}
@@ -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<JobResponse<Void>> 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
@@ -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.
*
* <p>Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
* assets[i].file} for each supporting asset.
* <p>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<NamedAsset> 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;
}
@@ -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,
@@ -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;
}
@@ -213,7 +213,9 @@ public class SecurityConfiguration {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
@@ -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<Class<?>> 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<String> names(Enum<?>[] values) {
return Arrays.stream(values).map(Enum::name).toList();
}
}
@@ -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<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<>();
@@ -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
@@ -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<FileRunEventView.ActionView> 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
@@ -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<String> 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<String> 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);
@@ -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<FileRunEventService.AvailableAction> 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<FailureActionId> 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<String, String> inputs, String actor) {
throw new UnsupportedOperationException();
}
}
}
@@ -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);
}
@@ -134,7 +134,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
Collection<FileRunEventStatus> 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
@@ -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();
});
}
}
}
@@ -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<FileRunEvent> 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<FailureActionId> 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);
}
}
}
@@ -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<FileRunEvent> 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);
}
}
}
@@ -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
@@ -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<String> 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<JobResponse<Void>> 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<JobResponse<Void>> response =
@@ -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());
@@ -316,7 +316,11 @@ public class SupabaseSecurityConfig {
"Origin",
"X-API-KEY",
"X-Browser-Id"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+1 -1
View File
@@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application (backend only, no frontend)
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
+1 -1
View File
@@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
ARG TASK_VERSION=3.52.0
RUN apt-get update \
+1 -1
View File
@@ -8,7 +8,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
ARG TASK_VERSION=3.52.0
RUN apt-get update \
+1 -1
View File
@@ -4,7 +4,7 @@
# Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS build
# Install Node.js and npm for frontend build
ARG TASK_VERSION=3.52.0
@@ -4312,11 +4312,14 @@ issues = "GitHub"
[formFill]
allSaved = "All saved"
analyzingFields = "Analysing form fields..."
applyFailed = "Could not apply the changes"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
filled = "filled"
flattenAfterFilling = "Flatten after filling"
goToPage = "Go to this page"
noFields = "No fillable form fields found in this PDF."
page = "Page"
placeholderEnter = "Enter"
placeholderSelect = "Select"
requiredAbbreviation = "req"
@@ -4325,8 +4328,83 @@ rescanFields = "Re-scan fields"
rescanFormFields = "Re-scan form fields"
save = "Save"
saveShortcut = "Ctrl+S to save"
skippedEdits_one = "1 change could not be applied:"
skippedEdits_other = "{{count}} changes could not be applied:"
skippedEditsTruncated = "{{count}} more not listed."
unsavedChanges = "Unsaved changes"
[formFill.create]
commit = "Add {{count}} field(s) to PDF"
empty = "No fields drawn yet."
failed = "Failed to add fields"
goToField = "Go to this field"
hint = "Pick a field type, then draw it on the page."
placing = "Draw a {{type}} field on the page. Press Esc to stop."
preview = "Hold to preview"
previewHelp = "Hold to see the fields as they will look once added, without the editing outlines."
removeField = "Remove field"
[formFill.editor]
action = "Button action"
actionHelp = "What the button does when clicked."
actionNone = "None"
actionPrint = "Print"
actionReset = "Reset form"
actionSubmit = "Submit to URL"
actionUri = "Open URL"
actionUrl = "URL"
actionUrlHelp = "The address the button opens or submits to."
addOption = "Add option"
caption = "Button caption"
captionHelp = "The text printed on the button face."
defaultValue = "Default value"
defaultValueHelp = "What the field contains before anyone fills it in. Leave blank for an empty field."
fontSize = "Font size"
fontSizeHelp = "Text size inside the field. Leave blank to let the reader size it to fit."
label = "Label"
labelHelp = "The wording shown to whoever fills the form. Leave it blank to fall back to the field name."
maxLength = "Max length (comb)"
maxLengthHelp = "Caps how many characters fit, drawn as evenly spaced boxes."
multiline = "Multi-line"
multilineHelp = "Allows more than one line of text and wraps at the field's edge."
multiSelect = "Allow multiple selection"
multiSelectHelp = "Lets more than one option be chosen at once."
name = "Field name"
nameHelp = "The field's internal name. Used when exporting data or filling the form from another system, so keep it unique and free of spaces."
optionGap = "Option spacing"
optionGapHelp = "Gap between buttons, in points. Leave blank to spread them evenly down the box."
optionPlaceholder = "Option {{n}}"
options = "Options"
optionsEmpty = "Add at least one option."
optionsHelp = "The choices offered in the list. Each one is stored as typed, so keep them short and distinct."
optionSize = "Option size"
optionSizeHelp = "Width and height of each button, in points. Leave blank to fit them to the box you drew."
readOnly = "Read-only"
readOnlyHelp = "Shows a value but stops anyone editing it."
removeOption = "Remove option"
required = "Required"
requiredHelp = "The form cannot be submitted until this field is filled in."
signatureNote = "Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed."
tooltip = "Tooltip"
tooltipHelp = "The hint shown when someone hovers the field in a PDF reader."
type = "Type"
typeHelp = "What kind of field this is. Changing it rebuilds the field, so its current value is not carried over."
[formFill.mode]
create = "Create"
fill = "Fill"
label = "Form editor mode"
modify = "Modify"
[formFill.modify]
commit = "Save {{count}} change(s)"
delete = "Delete"
empty = "This PDF has no form fields yet."
failed = "Failed to save changes"
groupSizeHint = "Use Option size"
hint = "Select a field to edit its properties, drag it on the page, or delete it."
restore = "Restore"
[formFill.sidebar]
close = "Close sidebar"
@@ -4657,8 +4735,8 @@ tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make
title = "Flatten"
[home.formFill]
desc = "Fill PDF form fields interactively with a visual editor"
title = "Fill Form"
desc = "Fill, create, edit, and delete PDF form fields with a visual editor"
title = "Form Editor"
[home.getPdfInfo]
desc = "Grabs any and all information possible on PDFs"
@@ -5109,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"
@@ -7631,6 +7733,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"
@@ -7642,6 +7746,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]
@@ -8930,7 +9036,9 @@ title = "Sources"
connectSource = "Connect source"
[portal.sources.builder]
advanced = "Advanced"
back = "Back to sources"
backToSource = "Back to source setup"
backToTypes = "All source types"
cancel = "Cancel"
chooseHint = "Choose where documents come from. Greyed-out connectors are on the way."
@@ -8940,7 +9048,6 @@ create = "Create source"
createTitle = "Connect a source"
delete = "Delete"
editTitle = "Edit source"
enabled = "Enabled"
save = "Save changes"
[portal.sources.builder.folderAccess]
@@ -8960,23 +9067,24 @@ total = "Connections"
unused = "Unused"
[portal.sources.networkFields.connection]
helperText = "The stored connection with the server address and credentials. Reused by every source that references it."
helperText = "The saved connection with the server address and credentials. Shared by every source that uses it."
label = "Connection"
[portal.sources.networkFields.directory]
helperText = "Folder on the server to poll, relative to the login home or share root. Leave blank for the root."
helperText = "The folder on the server to watch, relative to the login home or share root."
label = "Folder"
placeholder = "incoming/"
placeholder = "Login home or share root"
[portal.sources.networkFields.mode]
helperText = "Consume removes each file from the server once every policy has processed it."
label = "Read mode"
helperText = "Whether to leave the original file in place after it has been processed. If the original file is left in place, it will be re-processed next time the pipeline scans the source."
label = "After processing"
[portal.sources.networkFields.mode.options]
consume = "Consume: process each file once"
snapshot = "Snapshot: re-read the folder every run"
consume = "Delete the file"
snapshot = "Leave it in place"
[portal.sources.networkFields.recursive]
helperText = "Include subfolders if your files are organised into nested folders, or watch only the top level."
label = "Folder depth"
[portal.sources.networkFields.recursive.options]
@@ -9017,26 +9125,28 @@ description = "Watch a directory on the server for new documents."
label = "Folder"
[portal.sources.types.folder.fields.directory]
helperText = "Absolute path Stirling watches for files to process."
label = "Directory path"
helperText = "The absolute path to the folder to watch for new files."
label = "Folder"
placeholder = "/data/incoming"
[portal.sources.types.folder.fields.identity]
helperText = "Content check reads each changed file, so renames and touches that don't alter content are not reprocessed."
helperText = "Using the file contents for change detection is slower but more accurate."
label = "Change detection"
[portal.sources.types.folder.fields.identity.options]
hash = "Size, date and content check"
stat = "Size and date modified"
hash = "File Metadata & Content"
stat = "File Metadata"
[portal.sources.types.folder.fields.mode]
label = "Read mode"
helperText = "Whether to leave the original file in place after it has been processed. If the original file is left in place, it will be re-processed next time the pipeline scans the source."
label = "After processing"
[portal.sources.types.folder.fields.mode.options]
consume = "Consume: process each file once"
snapshot = "Snapshot: re-read the folder every run"
consume = "Delete the file"
snapshot = "Leave it in place"
[portal.sources.types.folder.fields.recursive]
helperText = "Include subfolders if your files are organised into nested folders, or watch only the top level."
label = "Folder depth"
[portal.sources.types.folder.fields.recursive.options]
@@ -9064,21 +9174,21 @@ description = "Pull documents from an Amazon S3 or S3-compatible bucket."
label = "Amazon S3"
[portal.sources.types.s3.fields.connection]
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
helperText = "The saved connection with the bucket and credentials. Shared by every source and pipeline output that uses it."
label = "Connection"
[portal.sources.types.s3.fields.mode]
helperText = "Consume removes each object from the bucket once every policy has processed it."
label = "Read mode"
helperText = "Whether to leave the original object in place after it has been processed. If the original object is left in place, it will be re-processed next time the pipeline scans the source."
label = "After processing"
[portal.sources.types.s3.fields.mode.options]
consume = "Consume: process each object once"
snapshot = "Snapshot: re-read the bucket every run"
consume = "Delete the object"
snapshot = "Leave it in place"
[portal.sources.types.s3.fields.prefix]
helperText = "Only objects whose keys start with this prefix are processed."
label = "Key prefix"
placeholder = "incoming/"
helperText = "The folder (key prefix) within the bucket to watch."
label = "Folder"
placeholder = "Whole bucket"
[portal.sources.types.sftp]
description = "Poll an SFTP drop folder for new documents."
@@ -11902,7 +12012,7 @@ downloadAll = "Download All"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
formFill = "Form Editor"
hideToolbar = "Hide toolbar"
moreActions = "More actions"
multiTool = "Multi-Tool"
+2 -2
View File
@@ -272,8 +272,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
+2 -2
View File
@@ -273,8 +273,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
+1 -1
View File
@@ -634,7 +634,7 @@ const CODE_EXEMPT_PATH = [
// PDF rendering/drawing surfaces that legitimately carry colour literals —
// scoped to specific tool paths, not a blanket "pdf" substring (which used to
// exempt most of the app in a PDF product).
/pdfTextEditor|pixelCompare|\/compare\.ts$|customPrimary|accentColors/,
/pdfTextEditor|pixelCompare|\/compare\.ts$|customPrimary|accentColors|formFieldColors/,
/validateSignature\/outputtedPDFSections|CenteredMessageSection|StatusBadgeSection/,
/\/viewer\/|Annotation|useViewerReadAloud|CommentsSidebar|\/constants\/search\.ts$|SignaturePreview/,
/ColorPicker|ColorControl|WatchedFolderManagementModal|watchedFolderPresets|fileColors|unifiedBackground|folder\.ts$|policyFolders/,
@@ -395,7 +395,7 @@
{
"moduleName": "license-report",
"moduleUrl": "https://github.com/bepo65/license-report",
"moduleVersion": "6.8.2",
"moduleVersion": "6.8.5",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -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 && (
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
<NotificationBell />
</div>
)}
{showWorkbenchBar && (
<div className={styles.workbenchBarShell}>
<div className={styles.workbenchBarWrapper}>
@@ -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);
}
@@ -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<typeof baseRender>[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<string, unknown>;
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> = {},
): 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> = {},
): 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(<NotificationBell />);
await Promise.resolve();
expect(screen.queryByRole("button")).toBeNull();
expect(fetchNotifications).not.toHaveBeenCalled();
});
it("shows no badge when there is nothing to report", async () => {
render(<NotificationBell />);
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(<NotificationBell />);
expect(await screen.findByText("2")).toBeTruthy();
});
it("clears the badge once the user opens the panel", async () => {
fetchNotifications.mockResolvedValue([
notification("a"),
notification("b"),
]);
render(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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(<NotificationBell />);
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();
});
});
@@ -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 <MountedNotificationBell />;
}
function MountedNotificationBell() {
const { t } = useTranslation();
const { notifications, unreadCount, documentStateFor, markAllSeen } =
useNotifications();
const registry = useNotificationActions();
const [open, setOpen] = useState(false);
const container = useRef<HTMLDivElement>(null);
const headingId = useId();
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
const [firstSeenId, setFirstSeenId] = useState<string | null>(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 (
<div className="notification-bell" ref={container}>
<Button
variant="quiet"
size="md"
shape="circle"
className="notification-bell__trigger"
aria-label={t("notifications.open", "Notifications")}
aria-expanded={open}
onClick={toggle}
>
<BellIcon />
{unreadCount > 0 && (
<span className="notification-bell__badge" aria-hidden>
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
{open && (
<div
className="notification-bell__panel"
role="dialog"
// Named by its own heading: a dialog with no accessible name is announced as just "dialog".
aria-labelledby={headingId}
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
>
<h2 className="notification-bell__heading" id={headingId}>
{t("notifications.title", "Notifications")}
</h2>
{notifications.length === 0 ? (
<p className="notification-bell__empty">
{t("notifications.empty", "Nothing to report.")}
</p>
) : (
<ul className="notification-bell__list">
{notifications.map((notification, index) => (
<Fragment key={notification.id}>
{index === 0 && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.new", "New")}
/>
</li>
)}
{/* Only with something on both sides: a lone "Earlier" over everything says
nothing the empty badge has not. */}
{index === dividedAt && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.earlier", "Earlier")}
/>
</li>
)}
<NotificationItem
notification={notification}
unread={index < dividedAt}
documentState={documentStateFor(notification)}
registry={registry}
onDismissPanel={() => setOpen(false)}
/>
</Fragment>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -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<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 (
<li
className="notification-bell__item"
data-severity={notification.severity.toLowerCase()}
>
{unread && (
<span
className="notification-bell__dot"
aria-label={t("notifications.unread", "Unread")}
/>
)}
<span className="notification-bell__item-title">{title}</span>
{notification.occurrences > 1 && (
<span className="notification-bell__count">
{t("notifications.occurrences", {
count: notification.occurrences,
defaultValue: "{{count}} times",
})}
</span>
)}
{notification.detail && (
<>
<span
className={
expanded
? "notification-bell__detail notification-bell__detail--full"
: "notification-bell__detail"
}
>
{notification.detail}
</span>
<span className="notification-bell__chrome">
<button
type="button"
className="notification-bell__chip"
aria-label={`${t("notifications.detail.copy", "Copy error")}: ${title}`}
onClick={() => void copyDetail()}
>
{copied
? t("notifications.detail.copied", "Copied")
: t("notifications.detail.copy", "Copy error")}
</button>
<button
type="button"
className="notification-bell__chip"
aria-expanded={expanded}
aria-label={`${
expanded
? t("notifications.detail.less", "Show less")
: t("notifications.detail.more", "Show full message")
}: ${title}`}
onClick={() => setExpanded((wasExpanded) => !wasExpanded)}
>
{expanded
? t("notifications.detail.less", "Show less")
: t("notifications.detail.more", "Show full message")}
</button>
</span>
</>
)}
{note && <span className="notification-bell__note">{note}</span>}
{/* In the kind's declared order, the first leading. */}
{usable.length > 0 && (
<span className="notification-bell__actions">
{usable.map((offer, index) => (
<ActionButton
key={offer.id}
variant={index === 0 ? "primary" : "secondary"}
rowTitle={title}
label={labelOf(offer)}
busy={busy === offer.id}
onRun={() => void run(offer)}
/>
))}
</span>
)}
{message && (
<span className="notification-bell__message" role="alert">
{message}
</span>
)}
</li>
);
}
interface ActionButtonProps {
variant: "primary" | "secondary";
rowTitle: string;
label: string;
busy: boolean;
onRun: () => void;
}
function ActionButton({
variant,
rowTitle,
label,
busy,
onRun,
}: ActionButtonProps) {
return (
<Button
variant={variant}
size="sm"
fontSize="xs"
className="notification-bell__cta"
disabled={busy}
// Every row's buttons read alike, so the label alone would not say which failure this acts on.
aria-label={`${label}: ${rowTitle}`}
onClick={onRun}
>
{label}
</Button>
);
}
@@ -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<ClientActionOutcome | void>;
/** 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<string, ClientActionSpec | undefined>
>;
const NONE: ClientActionRegistry = {};
/** Every destination ships in a higher layer, so this build's rows carry no buttons. */
export function useNotificationActions(): ClientActionRegistry {
return NONE;
}
@@ -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;
}
@@ -33,7 +33,6 @@ import {
useUnsavedChanges,
} from "@app/contexts/UnsavedChangesContext";
import { stripBasePath, withBasePath } from "@app/constants/app";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
interface AppConfigModalProps {
opened: boolean;
@@ -232,27 +231,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const handleClose = useCallback(async () => {
const canProceed = await confirmIfDirty();
if (!canProceed) return false;
// Only unwind history if settings was opened via the URL; opened via state
// there's no /settings entry to pop and navigate(-1) would jump to /files.
if (urlSync && location.pathname.startsWith("/settings")) {
// "default" key = first entry (deep link/refresh); nothing to pop to.
if (location.key === "default") {
navigate(EDITOR_BASENAME, { replace: true });
} else {
navigate(-1);
}
}
onClose();
return true;
}, [
confirmIfDirty,
location.key,
location.pathname,
navigate,
onClose,
urlSync,
]);
}, [confirmIfDirty, onClose]);
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
const handleCloseSync = useCallback(() => {
@@ -1,14 +1,8 @@
import { useRef, useEffect } from "react";
import { Modal, Text, Group, Stack, rem } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { IconBadge } from "@app/ui/IconBadge";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
import { UnsavedChangesDialog } from "@app/components/shared/UnsavedChangesDialog";
const NavigationWarningModal = () => {
const { t } = useTranslation();
const {
showNavigationWarning,
hasUnsavedChanges,
@@ -77,79 +71,13 @@ const NavigationWarningModal = () => {
}
return (
<Modal
<UnsavedChangesDialog
opened={showNavigationWarning}
onClose={handleKeepWorking}
centered
size={rem(400)}
radius="lg"
padding="xl"
withCloseButton={false}
overlayProps={{ blur: 4, opacity: 0.4 }}
transitionProps={{ transition: "pop", duration: 140 }}
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Modal.Title className="sr-only">
{t("unsavedChangesTitle", "Unsaved changes")}
</Modal.Title>
<Stack align="center" gap="md">
<IconBadge accent="amber" size="md">
<WarningAmberRoundedIcon style={{ fontSize: 22 }} />
</IconBadge>
<Stack gap={4} ta="center">
<Text fw={600} size="lg">
{t("unsavedChangesTitle", "Unsaved changes")}
</Text>
<Text size="sm" c="var(--c-text-muted)" lh={1.5}>
{t(
"unsavedChangesBody",
"You have unsaved changes to your PDF. Are you sure you want to leave?",
)}
</Text>
</Stack>
<Stack gap="sm" w="100%" mt="xs">
{hasApply && (
<Button
fullWidth
variant="primary"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Save & Leave")}
</Button>
)}
{hasExport && (
<Button
fullWidth
variant="primary"
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
<Group grow gap="sm" wrap="nowrap">
<Button
variant="secondary"
accent="neutral"
data-autofocus
onClick={handleKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
<Button
variant="secondary"
accent="danger"
onClick={handleDiscardChanges}
>
{t("discardChanges", "Discard & Leave")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
onKeepWorking={handleKeepWorking}
onDiscard={handleDiscardChanges}
onSave={hasApply ? handleApplyAndContinue : undefined}
onExport={hasExport ? handleExportAndContinue : undefined}
/>
);
};
@@ -0,0 +1,108 @@
/**
* The one "unsaved changes" dialog. Navigation and the form editor's tab switch both render it,
* so the choice looks identical wherever it interrupts you; only the actions behind it differ.
*/
import { Modal, Text, Group, Stack, rem } from "@mantine/core";
import { useTranslation } from "react-i18next";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { Button } from "@app/ui/Button";
import { IconBadge } from "@app/ui/IconBadge";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
export interface UnsavedChangesDialogProps {
opened: boolean;
saving?: boolean;
onKeepWorking: () => void;
onDiscard: () => void;
/** Omit to hide the button, as when there is nothing this caller can save. */
onSave?: () => void;
onExport?: () => void;
}
export function UnsavedChangesDialog({
opened,
saving = false,
onKeepWorking,
onDiscard,
onSave,
onExport,
}: UnsavedChangesDialogProps) {
const { t } = useTranslation();
const heading = t("unsavedChangesTitle", "Unsaved changes");
return (
<Modal
opened={opened}
onClose={onKeepWorking}
centered
size={rem(400)}
radius="lg"
padding="xl"
withCloseButton={false}
overlayProps={{ blur: 4, opacity: 0.4 }}
transitionProps={{ transition: "pop", duration: 140 }}
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Modal.Title className="sr-only">{heading}</Modal.Title>
<Stack align="center" gap="md">
<IconBadge accent="amber" size="md">
<WarningAmberRoundedIcon style={{ fontSize: 22 }} />
</IconBadge>
<Stack gap={4} ta="center">
<Text fw={600} size="lg">
{heading}
</Text>
<Text size="sm" c="var(--c-text-muted)" lh={1.5}>
{t(
"unsavedChangesBody",
"You have unsaved changes to your PDF. Are you sure you want to leave?",
)}
</Text>
</Stack>
<Stack gap="sm" w="100%" mt="xs">
{onSave && (
<Button
fullWidth
variant="primary"
loading={saving}
data-testid="unsaved-save"
onClick={onSave}
>
{t("applyAndContinue", "Save & Leave")}
</Button>
)}
{onExport && (
<Button fullWidth variant="primary" onClick={onExport}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
<Group grow gap="sm" wrap="nowrap">
<Button
variant="secondary"
accent="neutral"
data-autofocus
onClick={onKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
<Button
variant="secondary"
accent="danger"
data-testid="unsaved-discard"
onClick={onDiscard}
>
{t("discardChanges", "Discard & Leave")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
);
}
export default UnsavedChangesDialog;
@@ -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"];
@@ -608,6 +609,9 @@ export default function WorkbenchBar({
enforcingProgress={enforcingProgress}
/>
)}
{/* Last in the globals, so it is the rightmost control. */}
<div className="workbench-bar-divider workbench-bar-globals-sep" />
<NotificationBell />
</div>
</div>
);
@@ -289,16 +289,25 @@ export default function SuperSearch({
inputRef.current?.select();
};
// Focus handover from a closing dialog. Only the on-screen instance
// responds (offsetParent is null while display:none / unmounted hosts),
// and focus waits two frames so the dialog's own return-focus runs first.
// responds (offsetParent is null while a host is display:none / unmounted).
const onFocusRequest = () => {
const input = inputRef.current;
if (!input || input.offsetParent === null) return;
setOpen(true);
const grab = () => {
input.focus();
input.select();
};
requestAnimationFrame(() =>
requestAnimationFrame(() => {
input.focus();
input.select();
grab();
// The dialog's return-focus fires shortly after it closes and steals
// focus back once; re-grab it if that happens.
input.addEventListener("focusout", grab, { once: true });
window.setTimeout(
() => input.removeEventListener("focusout", grab),
250,
);
}),
);
};
@@ -43,6 +43,7 @@ import {
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents";
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
import { useMeasurementManager } from "@app/hooks/useMeasurementManager";
import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog";
@@ -782,8 +783,8 @@ const EmbedPdfViewerContent = ({
handleFormApply(blob);
}
};
window.addEventListener("formfill:apply", handler);
return () => window.removeEventListener("formfill:apply", handler);
window.addEventListener(FORM_APPLY_EVENT, handler);
return () => window.removeEventListener(FORM_APPLY_EVENT, handler);
}, [handleFormApply]);
// Apply layer visibility changes - reload the modified PDF into the viewer
@@ -1237,6 +1238,7 @@ const EmbedPdfViewerContent = ({
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
enableFormFill={shouldEnableFormFill}
formEditingActive={isFormFillToolActive}
isManualRedactionMode={isManualRedactMode}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
@@ -101,6 +101,9 @@ import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext";
import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler";
import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay";
import { FormCreationInteractionLock } from "@app/tools/formFill/FormCreationInteractionLock";
import { FormFieldCreationOverlay } from "@app/tools/formFill/FormFieldCreationOverlay";
import { FormFieldEditOverlay } from "@app/tools/formFill/FormFieldEditOverlay";
import { ButtonAppearanceOverlay } from "@app/tools/formFill/ButtonAppearanceOverlay";
import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay";
import { CommentsSidebar } from "@app/components/viewer/CommentsSidebar";
@@ -114,6 +117,8 @@ interface LocalEmbedPDFProps {
enableAnnotations?: boolean;
enableRedaction?: boolean;
enableFormFill?: boolean;
/** Structural create/modify overlays only mount while the Form tool owns the viewer. */
formEditingActive?: boolean;
isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: PdfAnnotationObject) => void;
@@ -207,6 +212,7 @@ export function LocalEmbedPDF({
enableAnnotations = false,
enableRedaction = false,
enableFormFill = false,
formEditingActive = false,
isManualRedactionMode = false,
showBakedAnnotations = true,
onSignatureAdded,
@@ -1006,6 +1012,7 @@ export function LocalEmbedPDF({
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
@@ -1153,6 +1160,28 @@ export function LocalEmbedPDF({
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
@@ -11,6 +11,7 @@
* For widgets without an appearance stream (unsigned fields, or fields whose
* PDF writer didn't embed one), we fall back to a translucent badge overlay.
*/
import { useStaleBakedFieldNames } from "@app/tools/formFill/FormFillContext";
import React, { useEffect, useMemo, useRef, useState, memo } from "react";
import {
renderSignatureFieldAppearances,
@@ -114,6 +115,7 @@ function SignatureFieldOverlayInner({
pageWidth,
pageHeight,
}: SignatureFieldOverlayProps) {
const staleNames = useStaleBakedFieldNames();
const [fields, setFields] = useState<ResolvedSignatureField[]>([]);
useEffect(() => {
@@ -135,8 +137,13 @@ function SignatureFieldOverlayInner({
}, [pdfSource]);
const pageFields = useMemo(
() => fields.filter((f) => f.pageIndex === pageIndex),
[fields, pageIndex],
// A staged move or delete leaves this bitmap stranded at the original rect, on top of the
// editor chrome, so it is dropped until the edit is applied and the appearance re-extracted.
() =>
fields.filter(
(f) => f.pageIndex === pageIndex && !staleNames.has(f.fieldName),
),
[fields, pageIndex, staleNames],
);
if (pageFields.length === 0) return null;
@@ -124,7 +124,7 @@ export function useViewerWorkbenchBarButtons(
const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers");
const commentsLabel = t("workbenchBar.toggleComments", "Comments");
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
const formFillLabel = t("workbenchBar.formFill", "Form Editor");
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings");
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
@@ -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) {
@@ -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);
@@ -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(
<MantineProvider>
<FileContextProvider>
<Controller />
</FileContextProvider>
</MantineProvider>,
);
}
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]);
});
});
@@ -456,11 +456,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
height="1.5rem"
/>
),
name: t("home.formFill.title", "Fill Form"),
name: t("home.formFill.title", "Form Editor"),
component: lazy(() => import("@app/tools/formFill/FormFill")),
description: t(
"home.formFill.desc",
"Fill PDF form fields interactively with a visual editor",
"Fill, create, edit, and delete PDF form fields with a visual editor",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
@@ -468,7 +468,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
endpoints: ["form-fill"],
automationSettings: null,
supportsAutomate: false,
synonyms: ["form", "fill", "fillable", "input", "field", "acroform"],
synonyms: [
"form",
"fill",
"fillable",
"input",
"field",
"acroform",
"edit",
"create",
"editor",
"modify",
"builder",
],
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -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 = <TParams>(
// 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);
@@ -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> = {},
): 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<AppNotification[]>((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<AppNotification[]>((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);
});
});
@@ -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<string, NotificationDocumentState>;
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<void> | 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<void> {
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<void> {
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,
};
}
@@ -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,
+24 -5
View File
@@ -29,6 +29,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import { stripBasePath } from "@app/constants/app";
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
import {
FilesPageProvider,
@@ -123,12 +124,30 @@ export default function HomePage() {
return () => window.removeEventListener("appConfig:open", handler);
}, []);
const handleCloseConfig = useCallback(() => {
setConfigModalOpen(false);
if (location.pathname.startsWith("/settings")) {
navigate(EDITOR_BASENAME, { replace: true });
// Where the user was before settings opened, so close can restore it. Null
// when opened directly on a /settings URL (deep link) - close falls back to
// the editor root.
const settingsOriginRef = useRef<string | null>(null);
const wasConfigOpenRef = useRef(false);
useEffect(() => {
if (configModalOpen && !wasConfigOpenRef.current) {
settingsOriginRef.current = location.pathname.startsWith("/settings")
? null
: location.pathname;
}
}, [location.pathname, navigate]);
wasConfigOpenRef.current = configModalOpen;
}, [configModalOpen, location.pathname]);
const handleCloseConfig = useCallback(() => {
// Restore the URL before clearing the flag, or a late /settings commit
// re-opens the modal. Read window.location, not useLocation: a tab switch
// updates the URL synchronously while the router's commit lags. Replace to
// the origin rather than navigate(-1), which webkit can drop.
if (stripBasePath(window.location.pathname).startsWith("/settings")) {
navigate(settingsOriginRef.current ?? EDITOR_BASENAME, { replace: true });
}
setConfigModalOpen(false);
}, [navigate]);
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
@@ -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";
@@ -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);
});
});
@@ -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<boolean> {
if (!isUsableId(fileId)) return false;
try {
const stub = await fileStorage.getStirlingFileStub(fileId as FileId);
return stub !== null;
} catch {
return false;
}
}
function isUsableId(fileId: string | null | undefined): fileId is string {
return typeof fileId === "string" && fileId.trim() !== "";
}
@@ -0,0 +1,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:<uuid>`), 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<AppNotification[]> {
try {
const response = await apiClient.get<NotificationsResponse>(
`${NOTIFICATIONS_PATH}?limit=${limit}`,
);
return response?.data?.notifications ?? [];
} catch {
return [];
}
}
@@ -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: [] } }),
);
}
/**
@@ -0,0 +1,94 @@
import type { Page } from "@playwright/test";
/**
* WebKit elides Blob-backed multipart part bodies from `route.request().postData()`, so read them
* at the XHR/fetch seam instead; part headers stay readable on every engine.
*/
const STORE_KEY = "__capturedMultipartParts";
/**
* Start recording the text of the `partName` part of any `FormData` the page
* posts, keyed by the request's URL pathname. Must be called before navigation.
*/
export async function captureMultipartPart(
page: Page,
partName: string,
): Promise<void> {
await page.addInitScript((name: string) => {
const store: Record<string, string> = {};
(window as unknown as Record<string, unknown>).__capturedMultipartParts =
store;
const record = (url: string, body: unknown): void => {
if (!(body instanceof FormData)) return;
const part = body.get(name);
if (!(part instanceof Blob)) return;
let pathname: string;
try {
pathname = new URL(url, window.location.href).pathname;
} catch {
return;
}
void part.text().then((text) => {
store[pathname] = text;
});
};
// axios posts through XHR; keep the URL from open() so the capture stays
// keyed by endpoint and a commit to the wrong URL still fails the spec.
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (
this: XMLHttpRequest,
...args: unknown[]
) {
(this as unknown as Record<string, unknown>).__capturedUrl = String(
args[1] ?? "",
);
return (originalOpen as (...a: unknown[]) => unknown).apply(this, args);
};
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (
this: XMLHttpRequest,
...args: unknown[]
) {
const url = (this as unknown as Record<string, unknown>).__capturedUrl;
record(typeof url === "string" ? url : "", args[0]);
return (originalSend as (...a: unknown[]) => unknown).apply(this, args);
};
// Mirror it on fetch so the capture survives if the api client moves off XHR.
const originalFetch = window.fetch;
window.fetch = function (
this: typeof window,
input: RequestInfo | URL,
init?: RequestInit,
) {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
record(url, init?.body);
return originalFetch.call(this, input, init);
};
}, partName);
}
/** The captured part text for `pathname`, or undefined if nothing posted yet. */
export function readCapturedPart(
page: Page,
pathname: string,
): Promise<string | undefined> {
return page.evaluate(
([key, path]) =>
(
(window as unknown as Record<string, unknown>)[key] as
| Record<string, string>
| undefined
)?.[path],
[STORE_KEY, pathname] as const,
);
}

Some files were not shown because too many files have changed in this diff Show More