mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge remote-tracking branch 'origin/main' into sweep/pr6500
This commit is contained in:
@@ -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
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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
|
||||
|
||||
+911
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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");
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
|
||||
+285
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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",
|
||||
|
||||
+374
@@ -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);
|
||||
}
|
||||
}
|
||||
+307
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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
|
||||
|
||||
+22
-15
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-4
@@ -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();
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
+44
-20
@@ -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);
|
||||
|
||||
+11
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -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,
|
||||
|
||||
+99
-35
@@ -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) {}
|
||||
}
|
||||
|
||||
+13
-3
@@ -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
|
||||
}
|
||||
+48
@@ -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) {}
|
||||
}
|
||||
+53
@@ -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());
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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) {}
|
||||
+24
-2
@@ -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
|
||||
|
||||
+14
-2
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+9
-2
@@ -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;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
+57
@@ -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();
|
||||
}
|
||||
}
|
||||
+94
-13
@@ -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
|
||||
|
||||
+59
-12
@@ -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
|
||||
|
||||
+36
-20
@@ -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);
|
||||
|
||||
+289
-35
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+168
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+70
-3
@@ -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 =
|
||||
|
||||
+49
-4
@@ -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();
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user