mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09466c018 | ||
|
|
897c72e9d9 | ||
|
|
732ef18ae5 | ||
|
|
f7a2c626c9 | ||
|
|
caeca0b88a | ||
|
|
c93feb5dfc | ||
|
|
f945cc7dc6 | ||
|
|
72b7892312 | ||
|
|
353df7a647 | ||
|
|
0d75715af2 | ||
|
|
b44202185a | ||
|
|
49c1e75ced | ||
|
|
826e487f00 | ||
|
|
bcad2cd486 | ||
|
|
79686a3a09 |
@@ -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
-3
@@ -40,12 +40,15 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
|
||||
# and cannot blank the committed default.
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -84,6 +87,8 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend against the shared v3 staging project"
|
||||
@@ -95,10 +100,47 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
dev:linked:
|
||||
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
|
||||
cmds:
|
||||
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
|
||||
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
|
||||
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
|
||||
# does not make the sharing safe, so avoid rebuilding one while the other runs.
|
||||
- cmd: |
|
||||
n=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
|
||||
echo ">> SaaS backend is up, starting self-hosted"
|
||||
break
|
||||
fi
|
||||
n=$((n + 1))
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
if [ "$n" -ge 150 ]; then
|
||||
echo ">> SaaS backend never answered; starting anyway"
|
||||
fi
|
||||
- task: dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
|
||||
|
||||
_run:saas:
|
||||
internal: true
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
|
||||
# Last, because dotenv is set-if-absent: app/* still decides everything else.
|
||||
dotenv:
|
||||
- 'app/.env.saas.local'
|
||||
- 'app/.env.saas'
|
||||
- 'frontend/editor/.env.saas.local'
|
||||
- 'frontend/editor/.env.saas'
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
@@ -111,12 +153,29 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
# Empty is the same as unset: the property defaults to empty and is blank-checked.
|
||||
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
|
||||
# Relocates configs/pipeline/logs, for a second backend in the same directory.
|
||||
# Empty is the same as unset: the reader blank-checks it.
|
||||
BASE_PATH: '{{.BASE_PATH | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
|
||||
# serves it at <base>/app/link. An explicit value still wins.
|
||||
SYSTEM_FRONTENDURL:
|
||||
sh: |
|
||||
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
|
||||
echo "${SYSTEM_FRONTENDURL}"
|
||||
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
|
||||
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
|
||||
else
|
||||
echo "{{.APP_BASE_URL}}"
|
||||
fi
|
||||
cmds:
|
||||
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
||||
# against SAAS_DB_* (production).
|
||||
|
||||
+13
-3
@@ -121,17 +121,17 @@ tasks:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
echo "https://${ref}.supabase.co"
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
cmds:
|
||||
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
|
||||
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
|
||||
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
@@ -173,6 +173,16 @@ tasks:
|
||||
OPEN: '{{.OPEN}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start frontend dev server against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: dev:saas
|
||||
vars:
|
||||
SAAS_ENV: staging
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
OPEN: '{{.OPEN}}'
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
deps:
|
||||
|
||||
@@ -121,6 +121,92 @@ tasks:
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
|
||||
# No engine: linking never calls it.
|
||||
linked:staging:
|
||||
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: staging }
|
||||
|
||||
linked:dev:
|
||||
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: dev }
|
||||
|
||||
linked:_all:
|
||||
internal: true
|
||||
vars:
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
|
||||
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
|
||||
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
|
||||
# race the self-hosted one, which keeps ./configs and its existing database.
|
||||
- task: 'backend:{{.SAAS_ENV}}:saas'
|
||||
vars:
|
||||
PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
|
||||
BASE_PATH: 'tmp/linked-saas'
|
||||
- task: frontend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
- task: backend:dev:linked
|
||||
vars:
|
||||
PORT: '{{.APP_BACKEND_PORT}}'
|
||||
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
- task: linked:_ready
|
||||
vars:
|
||||
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
|
||||
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
|
||||
# Waits for all four to answer, then prints where they landed.
|
||||
linked:_ready:
|
||||
internal: true
|
||||
cmds:
|
||||
- cmd: |
|
||||
n=0
|
||||
ok=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
ok=1
|
||||
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
|
||||
"http://localhost:{{.APP_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
|
||||
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
|
||||
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then break; fi
|
||||
n=$((n + 1))
|
||||
# `sleep` is a binary, not a builtin, and Windows has none.
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
echo ""
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo ">> all four answering"
|
||||
else
|
||||
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
|
||||
fi
|
||||
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
|
||||
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
|
||||
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
|
||||
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
|
||||
echo ""
|
||||
|
||||
dev:_all:
|
||||
internal: true
|
||||
vars:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -186,7 +186,7 @@ system:
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
|
||||
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
|
||||
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
|
||||
mobileScannerSettings:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
|
||||
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
|
||||
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
|
||||
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
|
||||
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
|
||||
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
|
||||
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
|
||||
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
|
||||
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
|
||||
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
|
||||
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
|
||||
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
|
||||
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
|
||||
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
|
||||
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
|
||||
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
|
||||
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
|
||||
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
|
||||
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
|
||||
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
|
||||
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
|
||||
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
|
||||
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
|
||||
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
|
||||
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
|
||||
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
|
||||
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
|
||||
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
|
||||
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
|
||||
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
|
||||
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
|
||||
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
|
||||
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
|
||||
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
|
||||
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
|
||||
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
|
||||
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
|
||||
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
|
||||
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
|
||||
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
|
||||
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
|
||||
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
|
||||
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
|
||||
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
|
||||
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
|
||||
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
|
||||
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
|
||||
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
|
||||
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
|
||||
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
|
||||
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
|
||||
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
|
||||
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
|
||||
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
|
||||
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
|
||||
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
|
||||
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
|
||||
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
|
||||
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
|
||||
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
|
||||
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
|
||||
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
|
||||
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
|
||||
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
|
||||
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
|
||||
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
|
||||
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,26 +1,21 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
|
||||
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
|
||||
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
|
||||
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
|
||||
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
|
||||
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
|
||||
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
|
||||
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
|
||||
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
|
||||
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
|
||||
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
|
||||
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
|
||||
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
|
||||
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
|
||||
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
|
||||
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
|
||||
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
|
||||
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
|
||||
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
|
||||
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
|
||||
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
|
||||
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
|
||||
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
|
||||
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
|
||||
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
|
||||
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
|
||||
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
|
||||
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
|
||||
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
|
||||
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
|
||||
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
|
||||
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
|
||||
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
|
||||
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
Binary file not shown.
@@ -1,34 +1,34 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
|
||||
Key Attributes: <No Attributes>
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIB/3nui1td5QCAggA
|
||||
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBDY04ug+QgB6t2TdOWPgdtIBIIE
|
||||
0IaMRXXtpzLzSjlpyQpLMWLX9Lu+MauINVQMpan8qspC3RGkGcCQUzTkliM3Ls5Q
|
||||
Pwv02iFlKAzUYg/Z5V/kONfDkuxjeZvLFjmzomtWNy6yIxp4ShZinH8AGon16J6E
|
||||
s1+xlQBBLZYrRXX7WCpnHKE2OKquOoFWpYcb23py6FlD7Uq6XB0LEHR+C35tgnTQ
|
||||
WkTFK/La+cbJ+zmWA11Nrnz5XzuWTrNoNB4ygVON78T9o25Hf4V8rWhSZj2N79+B
|
||||
QuCAvuqZyAO12aUI9sxZZyis00JOnX7xbAeOkJk8Hhk4iQRMUUudKb5rqLrh/lcm
|
||||
F9zZjpu6PxJh22ztnRik3L3LyZLdEhMJJGWk4Z/3tKO87K4EiluzwZhAfMLpqfxx
|
||||
qfRKu6By97pbfJFBKqBTzmli2eeJLOwhERlovIaDiublFU8o8RE92PxUPOr7kqL7
|
||||
3cx8Qx5AF2Mnu7ftcLIGgg/lN+haoxpACDkC5ZvTFCrGr7jD1DlkswSMoai9gknx
|
||||
IMjID9nq6pVWyBm+wt9cALeK2wNa5RsE9fFvF/DBathV/WNmBwjnTKCeX3uPP1nw
|
||||
CUE6d+zicrz79kRWRnmscE3phTTu3/O9TokCMe3rLzC0f+gOpIE7vXDSeRuek/xs
|
||||
7uahAAWm94cHdz8QIBR/Ub+fFyrz/VHStAGlZhs0SoVnCl+VnZ9D9OqiyqslOihg
|
||||
LMcNwH8QjEv4zRAU/Sf1OdVJItXyKfII5zSUCW/TpD/vWPlG80Ib/bc+H9uZDZsg
|
||||
OADQYSyWjxA6OUThbCi6Wr+OxFUuDwVaMXxKjz1xH3HjmjpWZeTJy6BAuqe/OLDg
|
||||
VxDdEyL8fgz+QaaM/uqFarVMTir2A5VYNJzTXh02rUn3mXXHbH7uZYSwSg7fJ/hU
|
||||
ycSUkr/TFe9ZfqKOg1+ZKDu7Q97/tkL7gBTQbPqitUSinGvBgtMZKTHBznEn8foq
|
||||
NL/VaFSR4MxTOxFyE2e+9riNJmR0tavZCSgA7LcJtcT9l62cbmwmMj8DvEw8fiSD
|
||||
AYpgwovMtDoVDVQGb7ixLMz8/ta1BB7zPpr2aK8x5pVz5c+9rW/NiWQ68LCpEiAc
|
||||
HxExUVR0b9thC5YvG4VepUtmZ768yTYyus9jDiDNwRH/qttmAosn4pq5gGK+IVao
|
||||
oJX5jcroYaQnvXDBwve2XXXKSkIWe62r8h7Jv6mxR9yBQdVeWNtCGQ5AYNJNxI0i
|
||||
ZbCmCcQJnIuMHLYddaIEmUuUBFOquQC9y/pVbMbmdWOMw5Nama+/q6bke/XGk81I
|
||||
/Ov2gNN4Eu2V9N9MzlF0GiAmk1784qITj9iDIiYXPESnQfybFyhi2DaUM+KmeHpB
|
||||
I2KHL2KA0EGVhBjvCd7FVAqDJL7Dy3nCiLxNiDKChCP9+DDXB2mEfZafltSWai6p
|
||||
FPfGZJImQ6NO4/I/2aeXIwr4urJVFt3mr2b6w+gGRjr4qur0ZcqpvvcA3Es+tMX1
|
||||
eY5Or9V8iw/wj0x+CrHvvsRBfvCTSN/yqweMr5p1xSZm3Hfz906/q8HSaHb/sNne
|
||||
HCjUiKWJ6WTrjDjf9ewYnXb6Qxs3P0zjuHwSrpbq0Pr3HQveQvO5Tfrwr5+ikK1k
|
||||
FyqiU4e4vjpLujkIj2dmH0CkJ6ase1j/rWU8nLr1XZSR
|
||||
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQnH1/C+tgQtDL2ETF
|
||||
DVH1SQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEG7VLFdF6M627msk
|
||||
RRRS94wEggTQEOPfMCPRwnTb88nNFAGHr586zkrtG0MUftf4Lgfwns0D5l8qErV2
|
||||
oQZqla9XWqzwc1tM6SyeCbP+86vMBLNl4NXN/F/8j+P2njyahBumx9tym0Fs8KSW
|
||||
P6/GSmBESJWNJ2vT4lGAsuQyPf+iHvd+RAJbhKCtxWHXMY2OK7j2suCaTJSB5Jz1
|
||||
yyPazN/PZSFtDKhMJJRWcQ1pGGsJYaRoJ1v6/05yWtPGGrYGmnDBZ2eKxVm5dncv
|
||||
iYfqaIJ2HXmYZLvmDWy9AkHQSF+mNIMEN8jHXw9l1wGPx3GYtqcRr3r/cPDTZLd6
|
||||
SAjNY/U2YZUBqPqxgFy8sc1kHX6dJAXgBSeR4Rb8GNB8Ry14tMgJRsdsHi1bpMQ/
|
||||
hoqi2mUzYs9I/nz1ncGUB44jtwpN1OgkN9EgQN6i/pN1IJtMkFCnjQ+Ejgi/FRgQ
|
||||
R4fpqDxab2NkFGNE8hWiS0nsjvRyAtnqMwf6+flYAUYumeRbUkkYMelYOQelyJVb
|
||||
OxvfBUr6XBdTVwBR1B5S1MtFtHyw32i6+RCx0S5jRvA7jdX3CVfbTMnk5xLJOrP4
|
||||
7vIckCJaac0NfRQUe812sYWe68LSec3bzz0E4cytyuN7c5u2s1X7i6qs5ITjE7A8
|
||||
1Z2m0m+PDH1XjVvbQpzoLmbv4Spzus1fMQ7bGUjjGJw2PyfT9uD4ukEF12VI+S/n
|
||||
T6ckOkbUha6t5A47KXPpN4VpCnPFvvsJ4ej/ijzVoo5UbZ358tvCBE2D4uu9/TMq
|
||||
hAhWPMnM64JfYRvz96axKy2xgCRGDfYIpTSqBRvCwX3j1MyVKKfjvzIsraHCMb9g
|
||||
+7ELpbBFB8rRSqV/8VRypWSxmSWhLlgTLgH1iPVd7riSzsxcnBAON2iUmgcE0IEV
|
||||
fPcD2uFGTtiNiXu8iZ0xgNZ0nrhquuiUO1hmO/tBquDia7IvyXMHedaugvxdOgu7
|
||||
sZ5YD0DJCGOKTPWvBAF3UZPBJ3kbv2zBl/zEQD5e2wcCo2Flubdwz1/Gf9TGehce
|
||||
TLz0csUdNXjGmu1wpzwBFdBECPUQ7xoLnwc/1K2AiPcktWdLSPjzTkw6ERsYP9NA
|
||||
5w1zi4KmgX2iG78mc/fqHUhppPnL0acLLGFWFKTjYK7mCnPSW5taoRl2EIW+BezK
|
||||
kQYrGz1aONC5ol9e9pmK6YHt7fkHiYqPs/pE44a2tuM80EZsfsz0Mn5RKUgAIOOL
|
||||
cLvK/zmaZ5pf24b8p9vD7kdlFqzEq+H2t5RGuyCGvanS5Z4LL/fDBjcsCh2E3N+i
|
||||
hTsLRPZmKVqeDBIHoyBtSpe5OhzNZTitd6k1JoLFECzHckJflLVEDR7lLvPTI5ko
|
||||
/xxDMxi9InTA62zoSokvFIfN95Rd2tXPqmj14gsZlrKT/3cUNmdva0YmgI2gluS0
|
||||
qT7zozaKHQDDDMzTjhVRheccZOoPuXgQNvnVaXUDBDNyxRSuy3BWnt5YVQRZBzPw
|
||||
HN71h6DxNar/eckRQ03inVn6tGlgwVan5w/JdS7fp1+ET0HF2N93T9f4ZzxHVbEV
|
||||
aam9K+1Vn3hZvL5L06Yq5MjNlIaH/RhMY6zlh5CHR7v+vjYIC02ctbZIrbGL3k2u
|
||||
JKOKDp2QMhTQQ6QQdzoR6BbRgFDGWz8bzOjtVsW2pY3ketp/7/tpfc4=
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
|
||||
Key Attributes: <No Attributes>
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIXl98lJJ1MUsCAggA
|
||||
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBAcT6pXTGm0w+LUzlVH0GpJBIIE
|
||||
0NfOk8+haqEuGskrV8+JJVQLgqpKiOmXBjkiSHGReF4UTocKiUAwrHbvLj+j1VLM
|
||||
TNM/G68+SzGuWxI7gxpzA9u7p4Is5+2Sji9KsMuAh2CQlEuzkFsVaD9KXF2rje7g
|
||||
0G+4+ExZtsjlt/UqG2plFuWzJwji4J82Cy5dir1MQOOAweq5zG5/nzVpMmNoc1lo
|
||||
B9PO18R3SpY6qIp8Q0+d1QJC8zsXi/KKQ3ODiS83x5BL4KkQfjYDK/Lfr9yk5a3t
|
||||
JN8wE5jkDyGCLGGWgwy7Xq5N7m+kvcdeIEqKP9g5k5uZ7LppsDFe9dpHVymTHZGu
|
||||
tGrB74vi4D28YNhuG5qkTjp6CEehSjMwgWEo0Y6ZGu4WQvoTmkne88zly5vUFNrw
|
||||
JFM57YqE8U0Gzy7c/zeGtPq8U7y/Pd4z3muZe9sLpFoFAC7Aoq5yw662mPEBZRVb
|
||||
MDw8fK1OY9fnj9qHwQbYAD5AT9GmpwEP4tWkB6qNiDJBR8Jn3VmQ1uwR7oH+BiwX
|
||||
Y0xWjgl39JcpMORhzJim7K788FEjDrxR1ptepowC4EKjSeq92BGpO+Flf+lY/xYS
|
||||
3QR64h/wJEx7M3FrD7qxSHguW3h8rSMPHQg3YThyBUYsCc1tNpgmhQXNHXlE6G7o
|
||||
vdlDawf0Oybq6KzhdU25/kJyTaM7suiDkwyZf8SIElSD8R2VdYmL2AeowJsi26Qc
|
||||
0f7l/cL/Pws0j4vxYY+6DD5uw+bCBvsjE5Y8Fw6t0xgYwnMCALjfKr2p3CW/Ifa/
|
||||
uynI7Hd548orqkddc834DO6gcPuXMUgZ75RFYglpnD+DDvOzvqh7mrgDiCURZuXd
|
||||
eZkF3sr4Wfn4YsQfM0XdfB0/dmzLnGGIzbW9cuB4VQUswDZ9KCnZVMZOC8AMKvSQ
|
||||
eZn8VEYSr+qT5m8yKSmeUUQga6G/jN6yHj2mV8ura3o1NHvQpy82lHX3M+2d+cs1
|
||||
PWTcYM3AwPpHAM2HyisPYOeNNiEKvo3mtyw2SgV4P6kavdNXFk/xA7mzDWr0QnNX
|
||||
/j4ZZFynhUz46joCC6bew0yyRfL1Jqy+XDvtEOmjhy96nJvUDb5IqsMY5ZHRmGkc
|
||||
yO3uVQu7kexLcA8mYA5OK1llWuyHxffTyGuL5C0q7+8mBvPrkCakUjsLGAgIWYTE
|
||||
ftJ6q8u8xyDghXhRM0lvcoVLjzzjCIDaGVqeXl6HtgJ4grUaNCjESIfsURFylVxk
|
||||
3jNFojsxHPtv+zYAG0otqedSKjZaG0uNivjBt/v21luSs+lqEKbv4122yzC8H6pG
|
||||
zrS6OGkKb8fIqz3D5nAezMFuMjd+ORiGf/IUJToCeluqVGwXMXExdDSCDf0hFJny
|
||||
6y/eKmA88lu6uHYe4TB7ZR2wPyIGl1HPN3xj7Dc/T3wEhCDycKLN4/fY9ZNw5U6E
|
||||
F5yVnZFdcaA6qHiY99xvtOPX/EmxibcV6C84QV3HDmdXgjEIH52I9oK0WEjRb2hd
|
||||
U2lCnZDNqthn3zn0DZ/aSe4HDe5SfLnzFFGyD1wvCTRcM25901Op4kgVD/BPwWH+
|
||||
4E7KiBh91UueWn7m5h1B8cEnpsHwpQLxq2ZdNYzp3ZFyzvzSUXe3QvPveehAgr0M
|
||||
lEXzn1/fJpmRPP5hvt6uYqZ+y90BkiT6UlANFHpoA6x0
|
||||
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQnH1/C+tgQtDL2ETF
|
||||
DVH1SQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEG7VLFdF6M627msk
|
||||
RRRS94wEggTQEOPfMCPRwnTb88nNFAGHr586zkrtG0MUftf4Lgfwns0D5l8qErV2
|
||||
oQZqla9XWqzwc1tM6SyeCbP+86vMBLNl4NXN/F/8j+P2njyahBumx9tym0Fs8KSW
|
||||
P6/GSmBESJWNJ2vT4lGAsuQyPf+iHvd+RAJbhKCtxWHXMY2OK7j2suCaTJSB5Jz1
|
||||
yyPazN/PZSFtDKhMJJRWcQ1pGGsJYaRoJ1v6/05yWtPGGrYGmnDBZ2eKxVm5dncv
|
||||
iYfqaIJ2HXmYZLvmDWy9AkHQSF+mNIMEN8jHXw9l1wGPx3GYtqcRr3r/cPDTZLd6
|
||||
SAjNY/U2YZUBqPqxgFy8sc1kHX6dJAXgBSeR4Rb8GNB8Ry14tMgJRsdsHi1bpMQ/
|
||||
hoqi2mUzYs9I/nz1ncGUB44jtwpN1OgkN9EgQN6i/pN1IJtMkFCnjQ+Ejgi/FRgQ
|
||||
R4fpqDxab2NkFGNE8hWiS0nsjvRyAtnqMwf6+flYAUYumeRbUkkYMelYOQelyJVb
|
||||
OxvfBUr6XBdTVwBR1B5S1MtFtHyw32i6+RCx0S5jRvA7jdX3CVfbTMnk5xLJOrP4
|
||||
7vIckCJaac0NfRQUe812sYWe68LSec3bzz0E4cytyuN7c5u2s1X7i6qs5ITjE7A8
|
||||
1Z2m0m+PDH1XjVvbQpzoLmbv4Spzus1fMQ7bGUjjGJw2PyfT9uD4ukEF12VI+S/n
|
||||
T6ckOkbUha6t5A47KXPpN4VpCnPFvvsJ4ej/ijzVoo5UbZ358tvCBE2D4uu9/TMq
|
||||
hAhWPMnM64JfYRvz96axKy2xgCRGDfYIpTSqBRvCwX3j1MyVKKfjvzIsraHCMb9g
|
||||
+7ELpbBFB8rRSqV/8VRypWSxmSWhLlgTLgH1iPVd7riSzsxcnBAON2iUmgcE0IEV
|
||||
fPcD2uFGTtiNiXu8iZ0xgNZ0nrhquuiUO1hmO/tBquDia7IvyXMHedaugvxdOgu7
|
||||
sZ5YD0DJCGOKTPWvBAF3UZPBJ3kbv2zBl/zEQD5e2wcCo2Flubdwz1/Gf9TGehce
|
||||
TLz0csUdNXjGmu1wpzwBFdBECPUQ7xoLnwc/1K2AiPcktWdLSPjzTkw6ERsYP9NA
|
||||
5w1zi4KmgX2iG78mc/fqHUhppPnL0acLLGFWFKTjYK7mCnPSW5taoRl2EIW+BezK
|
||||
kQYrGz1aONC5ol9e9pmK6YHt7fkHiYqPs/pE44a2tuM80EZsfsz0Mn5RKUgAIOOL
|
||||
cLvK/zmaZ5pf24b8p9vD7kdlFqzEq+H2t5RGuyCGvanS5Z4LL/fDBjcsCh2E3N+i
|
||||
hTsLRPZmKVqeDBIHoyBtSpe5OhzNZTitd6k1JoLFECzHckJflLVEDR7lLvPTI5ko
|
||||
/xxDMxi9InTA62zoSokvFIfN95Rd2tXPqmj14gsZlrKT/3cUNmdva0YmgI2gluS0
|
||||
qT7zozaKHQDDDMzTjhVRheccZOoPuXgQNvnVaXUDBDNyxRSuy3BWnt5YVQRZBzPw
|
||||
HN71h6DxNar/eckRQ03inVn6tGlgwVan5w/JdS7fp1+ET0HF2N93T9f4ZzxHVbEV
|
||||
aam9K+1Vn3hZvL5L06Yq5MjNlIaH/RhMY6zlh5CHR7v+vjYIC02ctbZIrbGL3k2u
|
||||
JKOKDp2QMhTQQ6QQdzoR6BbRgFDGWz8bzOjtVsW2pY3ketp/7/tpfc4=
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
|
||||
+139
-67
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>Calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
|
||||
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -72,13 +56,7 @@ public class AccountLinkClient {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/** The device credential a successful {@link #register} returns. */
|
||||
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
|
||||
|
||||
/**
|
||||
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
|
||||
* map auth failures (401/403) through rather than masking everything as a 502.
|
||||
*/
|
||||
/** A non-2xx reply from the SaaS account-link API. */
|
||||
public static class UpstreamException extends IOException {
|
||||
private final int status;
|
||||
|
||||
@@ -92,11 +70,7 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
|
||||
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
|
||||
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
|
||||
*/
|
||||
/** Authoritative deny (401/403) — the device credential is revoked or invalid. */
|
||||
public static final class RevokedException extends RuntimeException {
|
||||
private final int status;
|
||||
|
||||
@@ -110,46 +84,142 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the SaaS side hands back when it records a connect handshake. */
|
||||
public record ConnectRequestResult(
|
||||
String requestId, int expiresInSeconds, String authorizeUrl) {}
|
||||
|
||||
public enum ConnectClaimOutcome {
|
||||
/** Approved and collected; the credential fields are populated. */
|
||||
GRANTED,
|
||||
/** A re-authentication was approved. */
|
||||
CONFIRMED,
|
||||
/** No human decision yet. */
|
||||
PENDING,
|
||||
/** Declined, expired or already used. */
|
||||
REJECTED,
|
||||
/** SaaS unreachable or erroring. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public record ConnectClaimResult(
|
||||
ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
|
||||
static ConnectClaimResult of(ConnectClaimOutcome outcome) {
|
||||
return new ConnectClaimResult(outcome, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a connect handshake. */
|
||||
public ConnectRequestResult connectRequest(
|
||||
String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
|
||||
return connectRequest(name, callbackUrl, nonce, claimSecret, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
|
||||
* credential.
|
||||
*
|
||||
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
|
||||
* admin).
|
||||
* As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
|
||||
* treats this as a re-authentication and pins the handshake to the team we already belong to.
|
||||
*/
|
||||
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
|
||||
String body =
|
||||
instanceName == null || instanceName.isBlank()
|
||||
? "{}"
|
||||
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
|
||||
HttpRequest request =
|
||||
public ConnectRequestResult connectRequest(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
DeviceCredential credential)
|
||||
throws IOException {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
if (name != null && !name.isBlank()) {
|
||||
root.put("name", name);
|
||||
}
|
||||
root.put("callbackUrl", callbackUrl);
|
||||
root.put("nonce", nonce);
|
||||
root.put("claimSecret", claimSecret);
|
||||
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/register"))
|
||||
.header("Authorization", "Bearer " + supabaseJwt)
|
||||
.uri(uri("/api/v1/account-link/connect/request"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
|
||||
if (credential != null) {
|
||||
builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
|
||||
.header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
|
||||
}
|
||||
|
||||
HttpResponse<String> response = send(request);
|
||||
HttpResponse<String> response = send(builder.build());
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new UpstreamException(response.statusCode(), response.body());
|
||||
}
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
String deviceId = text(root, "deviceId");
|
||||
String deviceSecret = text(root, "deviceSecret");
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
throw new IOException("SaaS register response missing deviceId/deviceSecret");
|
||||
JsonNode body = mapper.readTree(response.body());
|
||||
String requestId = text(body, "requestId");
|
||||
if (requestId == null) {
|
||||
throw new IOException("SaaS connect response missing requestId");
|
||||
}
|
||||
String authorizeUrl = text(body, "authorizeUrl");
|
||||
if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
|
||||
throw new IOException("SaaS connect response carried no usable authorizeUrl");
|
||||
}
|
||||
return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the device credential for an approved handshake, proving possession of the claim
|
||||
* secret.
|
||||
*/
|
||||
public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("requestId", requestId);
|
||||
root.put("claimSecret", claimSecret);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/connect/claim"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(
|
||||
HttpRequest.BodyPublishers.ofString(
|
||||
mapper.writeValueAsString(root)))
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
log.debug("Connect claim failed (transport): {}", e.getMessage());
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 202) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
|
||||
}
|
||||
if (status >= 500 && status <= 599) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
|
||||
}
|
||||
if (status < 200 || status > 299) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
try {
|
||||
JsonNode body = mapper.readTree(response.body());
|
||||
Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
|
||||
// A re-authentication says so explicitly and carries no credential, so an absent
|
||||
// credential is only an error when we were expecting one.
|
||||
if ("confirmed".equals(text(body, "status"))) {
|
||||
return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
|
||||
}
|
||||
String deviceId = text(body, "deviceId");
|
||||
String deviceSecret = text(body, "deviceSecret");
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
log.warn("Connect claim succeeded but the reply carried no credential");
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
return new ConnectClaimResult(
|
||||
ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Connect claim parse failed: {}", e.getMessage());
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
|
||||
return new RegisterResult(deviceId, deviceSecret, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
|
||||
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
|
||||
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
|
||||
*/
|
||||
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||
try {
|
||||
@@ -174,17 +244,7 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current entitlement using the stored device credential. Three outcomes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>2xx → the parsed snapshot.
|
||||
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
|
||||
* the caller must BLOCK, not fail open.
|
||||
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
|
||||
* ("unknown" — the caller fails open).
|
||||
* </ul>
|
||||
*/
|
||||
/** Fetches the current entitlement using the stored device credential. */
|
||||
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
@@ -224,9 +284,6 @@ public class AccountLinkClient {
|
||||
/**
|
||||
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
|
||||
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
|
||||
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
|
||||
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
|
||||
* not advance its last-synced markers so the usage retries next sync.
|
||||
*/
|
||||
public InstanceEntitlement reportUsage(
|
||||
String deviceId,
|
||||
@@ -360,4 +417,19 @@ public class AccountLinkClient {
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.hasNonNull(field) ? node.get(field).asText() : null;
|
||||
}
|
||||
|
||||
/** Absolute http(s) with a host. */
|
||||
static boolean isAbsoluteHttpUrl(String candidate) {
|
||||
try {
|
||||
URI uri = URI.create(candidate.strip());
|
||||
String scheme = uri.getScheme();
|
||||
return uri.isAbsolute()
|
||||
&& scheme != null
|
||||
&& ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
|
||||
&& uri.getHost() != null
|
||||
&& !uri.getHost().isBlank();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-44
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
|
||||
*
|
||||
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
|
||||
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
|
||||
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
|
||||
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
|
||||
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
|
||||
* / test aid).
|
||||
*
|
||||
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||
*/
|
||||
/** Same-origin account-link surface on the self-hosted instance (combined billing). */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final ConnectService connectService;
|
||||
private final LocalUsageService localUsageService;
|
||||
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
|
||||
private final ObjectProvider<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
ConnectService connectService,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
this.service = service;
|
||||
this.connectService = connectService;
|
||||
this.localUsageService = localUsageService;
|
||||
this.syncServiceProvider = syncServiceProvider;
|
||||
}
|
||||
|
||||
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||
public record LinkRequest(String supabaseJwt, String name) {}
|
||||
/** {@code callbackUrl} is the portal telling us where its own callback route lives. */
|
||||
public record ConnectStartRequest(String name, String callbackUrl) {}
|
||||
|
||||
@PostMapping("/link")
|
||||
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
|
||||
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(java.util.Map.of("error", "supabaseJwt is required"));
|
||||
}
|
||||
/** {@code nonce} comes from the callback fragment the approval page redirected to. */
|
||||
public record ConnectCompleteRequest(String nonce) {}
|
||||
|
||||
/**
|
||||
* Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
|
||||
*/
|
||||
@PostMapping("/connect/start")
|
||||
public ResponseEntity<?> connectStart(
|
||||
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
|
||||
try {
|
||||
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
|
||||
return ResponseEntity.ok(
|
||||
connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
|
||||
} catch (AccountLinkClient.UpstreamException e) {
|
||||
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
|
||||
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
|
||||
// raw upstream body back to the browser.
|
||||
HttpStatus status =
|
||||
e.status() == HttpStatus.UNAUTHORIZED.value()
|
||||
|| e.status() == HttpStatus.FORBIDDEN.value()
|
||||
? HttpStatus.valueOf(e.status())
|
||||
: HttpStatus.BAD_GATEWAY;
|
||||
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
} catch (IOException e) {
|
||||
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
|
||||
// configured SaaS host/IP. Log it server-side; return the same opaque body the
|
||||
// UpstreamException branch does.
|
||||
log.warn("Account-link failed (transport): {}", e.getMessage());
|
||||
log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
} catch (IOException e) {
|
||||
// Same reasoning as /link: a transport message can carry the configured SaaS host.
|
||||
log.warn("Account-link connect failed (transport): {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-establishes the admin's SaaS session for a server that is already linked. */
|
||||
@PostMapping("/connect/reauth")
|
||||
public ResponseEntity<?> connectReauth(
|
||||
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
|
||||
try {
|
||||
return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
|
||||
} catch (AccountLinkClient.UpstreamException e) {
|
||||
log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
} catch (IOException e) {
|
||||
log.warn("Account-link reauth failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the callback page with the nonce it found in the fragment. */
|
||||
@PostMapping("/connect/complete")
|
||||
public ResponseEntity<ConnectService.ConnectStatus> connectComplete(
|
||||
@RequestBody(required = false) ConnectCompleteRequest req) {
|
||||
return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
|
||||
}
|
||||
|
||||
/** Everything we know about where the admin's browser is, for the callback. */
|
||||
private static ConnectService.CallbackHint callbackHint(
|
||||
ConnectStartRequest req, HttpServletRequest http) {
|
||||
return new ConnectService.CallbackHint(
|
||||
req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
|
||||
}
|
||||
|
||||
/**
|
||||
* This instance's base URL as the browser reached it, including any context path so a subpath
|
||||
* deployment builds a callback that actually resolves.
|
||||
*/
|
||||
private static String baseUrlOf(HttpServletRequest request) {
|
||||
String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
|
||||
String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
|
||||
String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
|
||||
String hostPort;
|
||||
if (forwardedHost != null) {
|
||||
hostPort = forwardedHost;
|
||||
} else {
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort =
|
||||
("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
|
||||
}
|
||||
String context = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
return scheme + "://" + hostPort + context;
|
||||
}
|
||||
|
||||
private static String firstHop(String headerValue) {
|
||||
if (headerValue == null || headerValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String first = headerValue.split(",")[0].strip();
|
||||
return first.isEmpty() ? null : first;
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<AccountLinkService.LinkStatus> status() {
|
||||
return ResponseEntity.ok(service.status());
|
||||
@@ -106,12 +155,7 @@ public class AccountLinkController {
|
||||
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
|
||||
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
|
||||
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
|
||||
* {@code 409} when metering is off (the sync bean is absent).
|
||||
*/
|
||||
/** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
|
||||
@PostMapping("/sync-now")
|
||||
public ResponseEntity<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
|
||||
+8
-27
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
|
||||
*
|
||||
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
|
||||
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
|
||||
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
|
||||
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
|
||||
*/
|
||||
/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "stirling.billing.account-link")
|
||||
public class AccountLinkProperties {
|
||||
|
||||
/** Master switch. When {@code false} (default) the feature is fully inert. */
|
||||
/** Master switch. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
|
||||
*
|
||||
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
|
||||
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
|
||||
*/
|
||||
/** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
|
||||
private String saasBaseUrl = "https://stirling.com/app";
|
||||
|
||||
/** Cached entitlement is reused for this long before a refresh is attempted. */
|
||||
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
|
||||
/** Connect/read timeout for the outbound SaaS calls. */
|
||||
private int requestTimeoutSeconds = 10;
|
||||
|
||||
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
|
||||
/** Phase 2 usage metering + daily sync. */
|
||||
private final Metering metering = new Metering();
|
||||
|
||||
/**
|
||||
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
|
||||
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
|
||||
* enforcement. Both default off; metering requires the master flag too. This is the production
|
||||
* safety key — flipping it on is what actually bills linked instances.
|
||||
* Separate from {@link #enabled} so linking can be exercised without billing anything. Both
|
||||
* default off, and metering needs the master flag as well.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Metering {
|
||||
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
|
||||
*/
|
||||
private int graceDays = 3;
|
||||
|
||||
/**
|
||||
* Dedup window for identical input sets. A re-run of the same inputs within this window is
|
||||
* treated as workflow chaining and not re-charged; the same inputs run again after it are
|
||||
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
|
||||
* costs the same on the instance and in the cloud.
|
||||
*/
|
||||
/** Dedup window for identical input sets. */
|
||||
private Duration workflowWindow = Duration.ofMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-24
@@ -1,6 +1,5 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
|
||||
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
|
||||
* The credential — not the JWT — authenticates all later unattended entitlement calls.
|
||||
*/
|
||||
/** Linking orchestrator (self-hosted side of combined billing). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@@ -38,24 +31,9 @@ public class AccountLinkService {
|
||||
/** Status of this instance's link, for the portal's "Account link" card. */
|
||||
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
|
||||
|
||||
/**
|
||||
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
|
||||
* credential.
|
||||
*
|
||||
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
|
||||
*/
|
||||
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
|
||||
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
|
||||
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
|
||||
entitlementCache.invalidate();
|
||||
log.info("Account-link: instance linked to team {}", result.teamId());
|
||||
return status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
|
||||
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
|
||||
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
|
||||
* revoked_at} set), then clears locally regardless.
|
||||
*/
|
||||
public void unlink() {
|
||||
credentialStore
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined billing).
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
|
||||
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Browser-mediated account linking, instance side. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectService {
|
||||
|
||||
/** Frontend route that consumes the callback fragment. */
|
||||
static final String CALLBACK_PATH = "/account-link/callback";
|
||||
|
||||
private static final int SECRET_BYTES = 32;
|
||||
|
||||
private final AccountLinkClient client;
|
||||
private final ConnectStateRepository stateRepo;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public ConnectService(
|
||||
AccountLinkClient client,
|
||||
ConnectStateRepository stateRepo,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.client = client;
|
||||
this.stateRepo = stateRepo;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public enum Phase {
|
||||
/** Nothing in flight and not linked. */
|
||||
NONE,
|
||||
/** A handshake is open, waiting for a leader to approve it on the SaaS site. */
|
||||
PENDING,
|
||||
/** Linked. */
|
||||
LINKED,
|
||||
/** The handshake outlived its window; start a new one. */
|
||||
EXPIRED,
|
||||
/** Declined or already used; start a new one. */
|
||||
REJECTED,
|
||||
/** SaaS could not be reached; the handshake is still valid and can be retried. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
/** What the portal renders. */
|
||||
public record ConnectStatus(
|
||||
Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
|
||||
static ConnectStatus of(Phase phase) {
|
||||
return new ConnectStatus(phase, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything we know about where the admin's browser actually is, in decreasing authority. */
|
||||
public record CallbackHint(
|
||||
String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
|
||||
|
||||
/** Opens a handshake and returns where to send the admin. */
|
||||
@Transactional
|
||||
public ConnectStatus start(String name, CallbackHint hint) throws IOException {
|
||||
if (credentialStore.isLinked()) {
|
||||
return status();
|
||||
}
|
||||
return open(name, hint, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a handshake that only re-establishes the admin's browser session, for an instance that
|
||||
* is already linked.
|
||||
*/
|
||||
@Transactional
|
||||
public ConnectStatus startReauth(CallbackHint hint) throws IOException {
|
||||
DeviceCredential credential =
|
||||
credentialStore
|
||||
.get()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IOException(
|
||||
"This server is not linked, so there is no session"
|
||||
+ " to re-establish"));
|
||||
return open(credential.getDeviceId(), hint, credential);
|
||||
}
|
||||
|
||||
private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
|
||||
throws IOException {
|
||||
String callbackUrl = resolveCallbackUrl(hint);
|
||||
if (callbackUrl == null) {
|
||||
throw new IOException(
|
||||
"Cannot determine where to send the admin back to; set system.frontendUrl");
|
||||
}
|
||||
String nonce = randomSecret();
|
||||
String claimSecret = randomSecret();
|
||||
|
||||
AccountLinkClient.ConnectRequestResult created =
|
||||
client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ConnectState state = new ConnectState();
|
||||
state.setId(ConnectState.SINGLETON_ID);
|
||||
state.setRequestId(created.requestId());
|
||||
state.setNonce(nonce);
|
||||
state.setClaimSecret(claimSecret);
|
||||
state.setCallbackUrl(callbackUrl);
|
||||
state.setAuthorizeUrl(created.authorizeUrl());
|
||||
state.setCreatedAt(now);
|
||||
state.setExpiresAt(
|
||||
now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
|
||||
stateRepo.save(state);
|
||||
|
||||
log.info("Account-link connect: handshake {} opened", created.requestId());
|
||||
return pendingStatus(state, now);
|
||||
}
|
||||
|
||||
/** Finishes a handshake from the callback the approval page redirected to. */
|
||||
@Transactional
|
||||
public ConnectStatus complete(String nonce) {
|
||||
Optional<ConnectState> found = stateRepo.findById(ConnectState.SINGLETON_ID);
|
||||
if (found.isEmpty()) {
|
||||
// Already finished (a double-submitted callback) or never started.
|
||||
return status();
|
||||
}
|
||||
ConnectState state = found.get();
|
||||
if (state.isExpired(LocalDateTime.now())) {
|
||||
stateRepo.delete(state);
|
||||
return ConnectStatus.of(Phase.EXPIRED);
|
||||
}
|
||||
if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
|
||||
log.warn(
|
||||
"Account-link connect: callback for handshake {} had a bad nonce",
|
||||
state.getRequestId());
|
||||
return ConnectStatus.of(Phase.REJECTED);
|
||||
}
|
||||
|
||||
AccountLinkClient.ConnectClaimResult claim =
|
||||
client.connectClaim(state.getRequestId(), state.getClaimSecret());
|
||||
return switch (claim.outcome()) {
|
||||
case GRANTED -> {
|
||||
credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
|
||||
entitlementCache.invalidate();
|
||||
stateRepo.delete(state);
|
||||
log.info("Account-link connect: linked to team {}", claim.teamId());
|
||||
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
|
||||
}
|
||||
case CONFIRMED -> {
|
||||
stateRepo.delete(state);
|
||||
log.info(
|
||||
"Account-link connect: session re-established for team {}", claim.teamId());
|
||||
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
|
||||
}
|
||||
case PENDING ->
|
||||
// The admin reached the callback before the approval committed. The row stays,
|
||||
// so a retry finishes it.
|
||||
ConnectStatus.of(Phase.PENDING);
|
||||
case REJECTED -> {
|
||||
stateRepo.delete(state);
|
||||
yield ConnectStatus.of(Phase.REJECTED);
|
||||
}
|
||||
case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
|
||||
};
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ConnectStatus status() {
|
||||
Optional<DeviceCredential> credential = credentialStore.get();
|
||||
if (credential.isPresent()) {
|
||||
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
|
||||
}
|
||||
Optional<ConnectState> state = stateRepo.findById(ConnectState.SINGLETON_ID);
|
||||
if (state.isEmpty()) {
|
||||
return ConnectStatus.of(Phase.NONE);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (state.get().isExpired(now)) {
|
||||
return ConnectStatus.of(Phase.EXPIRED);
|
||||
}
|
||||
return pendingStatus(state.get(), now);
|
||||
}
|
||||
|
||||
private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
|
||||
long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
|
||||
return new ConnectStatus(
|
||||
Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
|
||||
}
|
||||
|
||||
/** Decides the callback, preferring knowledge over inference. */
|
||||
String resolveCallbackUrl(CallbackHint hint) {
|
||||
String configured = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
|
||||
}
|
||||
String browserOrigin = originOf(hint.browserOrigin());
|
||||
if (browserOrigin != null) {
|
||||
String requested = hint.requestedCallbackUrl();
|
||||
if (requested != null && browserOrigin.equals(originOf(requested))) {
|
||||
return requested.strip();
|
||||
}
|
||||
return browserOrigin + CALLBACK_PATH;
|
||||
}
|
||||
return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
|
||||
? null
|
||||
: trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
|
||||
private static String originOf(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(candidate.strip());
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
return null;
|
||||
}
|
||||
int port = uri.getPort();
|
||||
boolean defaultPort =
|
||||
port == -1
|
||||
|| ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
return defaultPort
|
||||
? scheme + "://" + uri.getHost()
|
||||
: scheme + "://" + uri.getHost() + ":" + port;
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String value) {
|
||||
return value.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private String randomSecret() {
|
||||
byte[] buf = new byte[SECRET_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** Constant-time so a caller cannot probe the nonce a character at a time. */
|
||||
private static boolean nonceMatches(String candidate, String expected) {
|
||||
if (expected == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
candidate.getBytes(StandardCharsets.UTF_8),
|
||||
expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** The one in-flight "connect this server" handshake, instance side. */
|
||||
@Entity
|
||||
@Table(name = "account_link_connect_state")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ConnectState implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private Long id = SINGLETON_ID;
|
||||
|
||||
/** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
|
||||
@Column(name = "request_id", nullable = false, length = 64)
|
||||
private String requestId;
|
||||
|
||||
/** Correlator we minted. */
|
||||
@Column(name = "nonce", nullable = false, length = 128)
|
||||
private String nonce;
|
||||
|
||||
/** Secret we minted and sent to SaaS server to server. */
|
||||
@Column(name = "claim_secret", nullable = false, length = 128)
|
||||
private String claimSecret;
|
||||
|
||||
/** Where we asked the approval page to send the admin back to. */
|
||||
@Column(name = "callback_url", nullable = false, length = 2048)
|
||||
private String callbackUrl;
|
||||
|
||||
/** The approval URL handed to the browser, so a reload can offer it again. */
|
||||
@Column(name = "authorize_url", nullable = false, length = 2048)
|
||||
private String authorizeUrl;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return expiresAt != null && expiresAt.isBefore(now);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Data access for the singleton {@link ConnectState} row. */
|
||||
public interface ConnectStateRepository extends JpaRepository<ConnectState, Long> {}
|
||||
+2
-2
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* The device credential this self-hosted instance received when it linked a SaaS account
|
||||
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
|
||||
* The device credential this self-hosted instance received when it linked a SaaS account (combined
|
||||
* billing). Singleton — one instance links to exactly one SaaS team.
|
||||
*
|
||||
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
|
||||
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
|
||||
* Decides whether a request may proceed under combined billing on a self-hosted instance.
|
||||
*
|
||||
* <p>Rules (in order):
|
||||
*
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
|
||||
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
|
||||
* automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
|
||||
*
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
|
||||
+5
-5
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* The last time the instance metered a given input set this period — the local equivalent of the
|
||||
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
|
||||
* window</b>: an identical input set re-submitted within the window (see {@link
|
||||
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
|
||||
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
|
||||
* window so the same operation costs the same on the instance and in the cloud.
|
||||
* cloud's lineage join (combined billing). The meter dedups on a rolling <b>workflow window</b>: an
|
||||
* identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
|
||||
* is treated as workflow chaining and not re-charged, while the same inputs run again after the
|
||||
* window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
|
||||
* costs the same on the instance and in the cloud.
|
||||
*
|
||||
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
|
||||
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
|
||||
/** Persistence for the per-period metered input-set signatures (combined billing). */
|
||||
public interface MeteredInputSignatureRepository
|
||||
extends JpaRepository<MeteredInputSignature, Long> {
|
||||
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
|
||||
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
|
||||
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
|
||||
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined billing. Each
|
||||
* successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
|
||||
* bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
|
||||
* and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
|
||||
*/
|
||||
@Entity
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
|
||||
/** Persistence for the per-period/per-category usage counters (combined billing). */
|
||||
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
|
||||
* usage to SaaS, which bills the delta against its own last-seen totals.
|
||||
* Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
|
||||
* SaaS, which bills the delta against its own last-seen totals.
|
||||
*
|
||||
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
|
||||
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@ import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
|
||||
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
|
||||
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
|
||||
* of file size), hardware-accelerated by the JVM where available.
|
||||
* meter (combined billing), so both derive an <em>identical</em> signature for the same bytes — the
|
||||
* basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
|
||||
* size), hardware-accelerated by the JVM where available.
|
||||
*
|
||||
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
|
||||
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
|
||||
|
||||
+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);
|
||||
|
||||
+67
-27
@@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
|
||||
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
|
||||
* unreachable) behaviour hold.
|
||||
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect
|
||||
* handshake refuses an authorize URL it would not navigate to and carries no user token, and that
|
||||
* entitlement parsing + the fail-open (null on unreachable) behaviour hold.
|
||||
*/
|
||||
class AccountLinkClientTest {
|
||||
|
||||
@@ -48,39 +48,79 @@ class AccountLinkClientTest {
|
||||
return resp;
|
||||
}
|
||||
|
||||
// register() is gone with the JWT relay, and with it the two tests that asserted this client
|
||||
// sends an Authorization: Bearer header. Nothing here carries a user token any more.
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void registerRelaysJwtAndParsesCredential() throws Exception {
|
||||
// Build the stub response first: nesting response() inside when() trips Mockito's
|
||||
// unfinished-stubbing check (inner when() runs mid outer when()).
|
||||
void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception {
|
||||
// The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail
|
||||
// loudly here rather than reach the admin.
|
||||
HttpResponse<String> resp =
|
||||
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
|
||||
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
|
||||
|
||||
assertEquals("dev-1", result.deviceId());
|
||||
assertEquals("sec-1", result.deviceSecret());
|
||||
assertEquals(42L, result.teamId());
|
||||
|
||||
HttpRequest sent = captor.getValue();
|
||||
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
|
||||
assertEquals(
|
||||
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
|
||||
assertThrows(
|
||||
java.io.IOException.class,
|
||||
() -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
|
||||
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
|
||||
void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(
|
||||
201,
|
||||
"{\"requestId\":\"req-1\",\"expiresIn\":900,"
|
||||
+ "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
|
||||
AccountLinkClient.ConnectRequestResult result =
|
||||
client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret");
|
||||
|
||||
assertEquals("req-1", result.requestId());
|
||||
assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl());
|
||||
// No user token on this call, by design.
|
||||
assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void connectClaimGrantsTheCredentialOnSuccess() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
AccountLinkClient.UpstreamException ex =
|
||||
assertThrows(
|
||||
AccountLinkClient.UpstreamException.class,
|
||||
() -> client.register("jwt", null));
|
||||
assertEquals(401, ex.status());
|
||||
|
||||
AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret");
|
||||
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome());
|
||||
assertEquals("dev-1", result.deviceId());
|
||||
assertEquals("sec-1", result.deviceSecret());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void connectClaimMapsTheStatusItIsGiven() throws Exception {
|
||||
// The whole point of these four: a claim consumes the request server-side, so
|
||||
// reading 200 as anything but success loses the credential irrecoverably.
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}"));
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}"));
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}"));
|
||||
assertEquals(
|
||||
AccountLinkClient.ConnectClaimOutcome.CONFIRMED,
|
||||
claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body)
|
||||
throws Exception {
|
||||
// Built before the when(), not inside it: response() stubs a mock of its own, and
|
||||
// Mockito cannot have that happen mid-stubbing.
|
||||
HttpResponse<String> resp = response(status, body);
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
return client.connectClaim("req-1", "secret").outcome();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+40
-33
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
|
||||
|
||||
/**
|
||||
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
|
||||
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
|
||||
* faults are a 502.
|
||||
* The local (self-hosted) account-link controller's error mapping. Every upstream or transport
|
||||
* failure is a 502, and the response body never echoes the exception, because a DNS or TLS message
|
||||
* can carry the configured SaaS host.
|
||||
*/
|
||||
class AccountLinkControllerTest {
|
||||
|
||||
private AccountLinkService service;
|
||||
private ConnectService connectService;
|
||||
private UsageSyncService syncService;
|
||||
private ObjectProvider<UsageSyncService> syncProvider;
|
||||
private AccountLinkController controller;
|
||||
@@ -32,47 +32,54 @@ class AccountLinkControllerTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
service = mock(AccountLinkService.class);
|
||||
connectService = mock(ConnectService.class);
|
||||
syncService = mock(UsageSyncService.class);
|
||||
syncProvider = mock(ObjectProvider.class);
|
||||
controller =
|
||||
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
|
||||
new AccountLinkController(
|
||||
service, connectService, mock(LocalUsageService.class), syncProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_missingJwt_returns400() {
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
// These asserted POST /link's error mapping, which distinguished 401/403 so the portal could
|
||||
// prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with
|
||||
// it: connect/start carries no user token, so an upstream refusal is never the admin's session
|
||||
// and everything non-transport is a plain gateway failure.
|
||||
|
||||
@Test
|
||||
void link_upstreamUnauthorized_maps401() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_upstreamForbidden_maps403() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_upstreamServerError_maps502() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
void connectStart_upstreamFailure_maps502() throws Exception {
|
||||
when(connectService.start(any(), any()))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectStart(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_transportFailure_maps502() throws Exception {
|
||||
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception {
|
||||
when(connectService.start(any(), any()))
|
||||
.thenThrow(new IOException("connection refused to saas.internal:8081"));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectStart(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
// The body must not echo the exception: a DNS/TLS message can carry the configured SaaS
|
||||
// host.
|
||||
assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal");
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectReauth_onAnUnlinkedServer_maps502() throws Exception {
|
||||
when(connectService.startReauth(any())).thenThrow(new IOException("not linked"));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectReauth(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
/** Minimal request: the controller only reads Origin and the forwarded/host details from it. */
|
||||
private static jakarta.servlet.http.HttpServletRequest request() {
|
||||
return new org.springframework.mock.web.MockHttpServletRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
-16
@@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink;
|
||||
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 static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -30,33 +28,25 @@ class AccountLinkServiceTest {
|
||||
service = new AccountLinkService(client, store, cache);
|
||||
}
|
||||
|
||||
// The two link() tests here are gone with the JWT relay. Storing a credential and invalidating
|
||||
// the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what
|
||||
// remains in this service is status and unlink.
|
||||
|
||||
@Test
|
||||
void link_storesCredentialAndInvalidatesCache() throws IOException {
|
||||
when(client.register("jwt", "name"))
|
||||
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
|
||||
void status_linkedFromTheStoredCredential() {
|
||||
DeviceCredential stored = new DeviceCredential();
|
||||
stored.setDeviceId("dev-1");
|
||||
stored.setTeamId(7L);
|
||||
stored.setLinkedAt(LocalDateTime.now());
|
||||
when(store.get()).thenReturn(Optional.of(stored));
|
||||
|
||||
AccountLinkService.LinkStatus status = service.link("jwt", "name");
|
||||
AccountLinkService.LinkStatus status = service.status();
|
||||
|
||||
verify(store).save("dev-1", "sec-1", 7L);
|
||||
verify(cache).invalidate();
|
||||
assertTrue(status.linked());
|
||||
assertEquals("dev-1", status.deviceId());
|
||||
assertEquals(7L, status.teamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_propagatesRegisterFailure() throws IOException {
|
||||
when(client.register(any(), any())).thenThrow(new IOException("boom"));
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IOException.class, () -> service.link("jwt", null));
|
||||
verify(cache, org.mockito.Mockito.never()).invalidate();
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_unlinkedWhenNoCredential() {
|
||||
when(store.get()).thenReturn(Optional.empty());
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult;
|
||||
import stirling.software.proprietary.accountlink.ConnectService.Phase;
|
||||
|
||||
/** Unit tests for the instance half of the connect handshake. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectServiceTest {
|
||||
|
||||
private static final String NONCE = "the-nonce";
|
||||
private static final String CLAIM_SECRET = "the-claim-secret";
|
||||
private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1";
|
||||
|
||||
@Mock private AccountLinkClient client;
|
||||
@Mock private ConnectStateRepository stateRepo;
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ConnectService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
service =
|
||||
new ConnectService(
|
||||
client,
|
||||
stateRepo,
|
||||
credentialStore,
|
||||
entitlementCache,
|
||||
applicationProperties);
|
||||
}
|
||||
|
||||
private void configureFrontendUrl(String url) {
|
||||
applicationProperties.getSystem().setFrontendUrl(url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception {
|
||||
configureFrontendUrl("https://pdf.example.com/");
|
||||
stubCreate();
|
||||
|
||||
service.start("prod-1", fromRequest("http://10.0.0.5:8080"));
|
||||
|
||||
verify(client)
|
||||
.connectRequest(
|
||||
anyString(),
|
||||
// Trailing slash trimmed, and the request's own view ignored.
|
||||
org.mockito.ArgumentMatchers.eq(
|
||||
"https://pdf.example.com" + ConnectService.CALLBACK_PATH),
|
||||
anyString(),
|
||||
anyString(),
|
||||
// A first link carries no credential; that is what makes it a first link.
|
||||
org.mockito.ArgumentMatchers.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
service.start(null, fromRequest("https://pdf.internal:8443/stirling"));
|
||||
|
||||
ArgumentCaptor<String> callback = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any());
|
||||
// Context path preserved, so a subpath deployment gets a callback that resolves.
|
||||
assertThat(callback.getValue())
|
||||
.isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_withNoAddressAtAllFailsRatherThanGuessing() {
|
||||
assertThat(catchIo(() -> service.start(null, fromRequest(null))))
|
||||
.hasMessageContaining("system.frontendUrl");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() {
|
||||
// The frontend is the only party that knows its router's base path.
|
||||
String requested = "http://localhost:5173/app/account-link/callback";
|
||||
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
requested,
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo(requested);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_ignoresACallbackFromADifferentOrigin() {
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
"https://evil.example.com/steal",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() {
|
||||
// The whole point: :5173 is where the admin is, :8080 is where the call landed.
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
null, "http://localhost:5173", "http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_letsConfigurationBeatEverything() {
|
||||
configureFrontendUrl("https://pdf.example.com/");
|
||||
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
"http://localhost:5173/account-link/callback",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_ignoresAnUnusableOriginHeader() {
|
||||
// "null" is what a browser sends for an opaque origin; it must not become a callback.
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
null, "null", "http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
ConnectService.ConnectStatus status =
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.PENDING);
|
||||
// Not composed here: only the SaaS side knows where its approval page lives, so an
|
||||
// instance configuring that could only get it wrong.
|
||||
assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_keepsTheNonceAndClaimSecretItSent() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
ArgumentCaptor<String> nonce = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> secret = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any());
|
||||
|
||||
ArgumentCaptor<ConnectState> saved = ArgumentCaptor.forClass(ConnectState.class);
|
||||
verify(stateRepo).save(saved.capture());
|
||||
assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue());
|
||||
assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue());
|
||||
// Two independent secrets, not one value used twice.
|
||||
assertThat(nonce.getValue()).isNotEqualTo(secret.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_whenAlreadyLinkedDoesNothing() throws Exception {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
|
||||
|
||||
ConnectService.ConnectStatus status =
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
verifyNoInteractions(client);
|
||||
verify(stateRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim("req-1", CLAIM_SECRET))
|
||||
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete(NONCE);
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
assertThat(status.teamId()).isEqualTo(7L);
|
||||
verify(credentialStore).save("dev", "sec", 7L);
|
||||
verify(entitlementCache).invalidate();
|
||||
verify(stateRepo).delete(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete("not-the-nonce");
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.REJECTED);
|
||||
// The important half: an unverified caller cannot cancel a legitimate handshake.
|
||||
verify(stateRepo, never()).delete(any());
|
||||
verifyNoInteractions(credentialStore);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withNoNonceAtAllIsRejected() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
|
||||
assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING);
|
||||
verify(stateRepo, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE);
|
||||
verify(stateRepo, never()).delete(any());
|
||||
verifyNoInteractions(credentialStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenDeclinedClearsTheHandshake() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED);
|
||||
verify(stateRepo).delete(state);
|
||||
verifyNoInteractions(credentialStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_onAnExpiredHandshakeClearsItWithoutClaiming() {
|
||||
ConnectState state = openHandshake();
|
||||
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED);
|
||||
verify(stateRepo).delete(state);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception {
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
|
||||
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
|
||||
|
||||
service.startReauth(fromRequest("https://pdf.example.com"));
|
||||
|
||||
// Sending the credential is what makes the pinning trustworthy: the team comes from
|
||||
// something only this instance holds.
|
||||
verify(client)
|
||||
.connectRequest(
|
||||
any(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
c -> c != null && "dev".equals(c.getDeviceId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReauth_onAnUnlinkedServerFails() {
|
||||
assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com"))))
|
||||
.hasMessageContaining("not linked");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_onAConfirmedReauthKeepsTheExistingCredential() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete(NONCE);
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
assertThat(status.teamId()).isEqualTo(7L);
|
||||
// Nothing to store: a second credential would orphan the one we already hold.
|
||||
verify(credentialStore, never()).save(anyString(), anyString(), any());
|
||||
verify(stateRepo).delete(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() {
|
||||
assertThat(service.status().phase()).isEqualTo(Phase.NONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() {
|
||||
ConnectState state = openHandshake();
|
||||
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
ConnectService.ConnectStatus status = service.status();
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.EXPIRED);
|
||||
assertThat(status.authorizeUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_countsDownWhileAHandshakeIsOpen() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
|
||||
ConnectService.ConnectStatus status = service.status();
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.PENDING);
|
||||
assertThat(status.secondsRemaining()).isPositive();
|
||||
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
|
||||
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
|
||||
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
|
||||
}
|
||||
|
||||
private void stubCreate() throws Exception {
|
||||
// The five-argument overload: a first link passes a null credential rather than none.
|
||||
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
|
||||
}
|
||||
|
||||
private static ConnectState openHandshake() {
|
||||
ConnectState state = new ConnectState();
|
||||
state.setId(ConnectState.SINGLETON_ID);
|
||||
state.setRequestId("req-1");
|
||||
state.setNonce(NONCE);
|
||||
state.setClaimSecret(CLAIM_SECRET);
|
||||
state.setCallbackUrl("https://pdf.example.com/account-link/callback");
|
||||
state.setAuthorizeUrl("https://app.example.com/link?request=req-1");
|
||||
state.setCreatedAt(LocalDateTime.now());
|
||||
state.setExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DeviceCredential credential(Long teamId) {
|
||||
DeviceCredential credential = new DeviceCredential();
|
||||
credential.setDeviceId("dev");
|
||||
credential.setDeviceSecret("sec");
|
||||
credential.setTeamId(teamId);
|
||||
credential.setLinkedAt(LocalDateTime.now());
|
||||
return credential;
|
||||
}
|
||||
|
||||
/** Runs a throwing call and returns the exception, so the assertion reads in one line. */
|
||||
private static Throwable catchIo(ThrowingCall call) {
|
||||
try {
|
||||
call.run();
|
||||
throw new AssertionError("expected the call to fail");
|
||||
} catch (Exception e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
private interface ThrowingCall {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+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());
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-85
@@ -4,14 +4,12 @@ import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
|
||||
|
||||
/**
|
||||
* Account-link registration surface (combined-billing "Mode A").
|
||||
*
|
||||
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
|
||||
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
|
||||
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
|
||||
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
|
||||
* JWT.
|
||||
*
|
||||
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
|
||||
* 404. Leader-only, and the team is always derived from the caller (never the request body).
|
||||
*/
|
||||
/** Team-wide management of linked instances (combined billing). */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final LeaderTeamResolver leaderTeams;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository) {
|
||||
public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) {
|
||||
this.service = service;
|
||||
this.memberRepo = memberRepo;
|
||||
this.userRepository = userRepository;
|
||||
this.leaderTeams = leaderTeams;
|
||||
}
|
||||
|
||||
/** Optional display name for the instance (hostname / label). */
|
||||
public record RegisterRequest(String name) {}
|
||||
|
||||
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
|
||||
public record RegisterResponse(
|
||||
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
|
||||
|
||||
public record InstanceRow(
|
||||
Long instanceId,
|
||||
String deviceId,
|
||||
@@ -74,31 +44,10 @@ public class AccountLinkController {
|
||||
String lastSeenAt,
|
||||
boolean revoked) {}
|
||||
|
||||
@PostMapping("/register")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<RegisterResponse> register(
|
||||
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
String name = req != null ? req.name() : null;
|
||||
AccountLinkService.RegisteredInstance reg =
|
||||
service.register(lt.teamId(), lt.userId(), name);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
new RegisterResponse(
|
||||
reg.instanceId(),
|
||||
lt.teamId(),
|
||||
reg.deviceId(),
|
||||
reg.deviceSecret(),
|
||||
reg.name()));
|
||||
}
|
||||
|
||||
@GetMapping("/instances")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
@@ -124,38 +73,11 @@ public class AccountLinkController {
|
||||
@PostMapping("/instances/{instanceId}/revoke")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
boolean ok = service.revoke(lt.teamId(), instanceId);
|
||||
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
|
||||
*/
|
||||
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
|
||||
|
||||
private LeaderTeam resolveLeaderTeam(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty()) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
TeamMembership m = rows.getFirst();
|
||||
if (m.getRole() != TeamRole.LEADER) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Account-link instance registration + lifecycle (combined-billing "Mode A").
|
||||
*
|
||||
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
|
||||
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
|
||||
* unattended entitlement reads with that credential.
|
||||
*
|
||||
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
|
||||
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
|
||||
*/
|
||||
/** Account-link instance registration + lifecycle (combined billing). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@@ -80,10 +71,7 @@ public class AccountLinkService {
|
||||
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
|
||||
* a different team (so a caller can never revoke another team's instance). Idempotent.
|
||||
*/
|
||||
/** Revokes an instance iff it belongs to {@code teamId}. */
|
||||
@Transactional
|
||||
public boolean revoke(Long teamId, Long instanceId) {
|
||||
Optional<LinkedInstance> found = repo.findById(instanceId);
|
||||
@@ -99,13 +87,30 @@ public class AccountLinkService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an active instance from a device credential, or empty if it does not authenticate.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<LinkedInstance> resolveActiveInstance(String deviceId, String deviceSecret) {
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
|
||||
.filter(
|
||||
instance ->
|
||||
MessageDigest.isEqual(
|
||||
sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8),
|
||||
instance.getDeviceSecretHash()
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private String randomSecret() {
|
||||
byte[] buf = new byte[SECRET_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
|
||||
/** SHA-256 hex of a value. */
|
||||
static String sha256Hex(String value) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
|
||||
|
||||
/** Browser-mediated "connect this server" handshake. */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/account-link/connect")
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectController {
|
||||
|
||||
/** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */
|
||||
static final String HEADER_DEVICE_ID = "X-Device-Id";
|
||||
|
||||
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
|
||||
|
||||
/** Frontend route serving the approval page. */
|
||||
static final String LINK_PATH = "/link";
|
||||
|
||||
private final ConnectRequestService service;
|
||||
private final LeaderTeamResolver leaderTeams;
|
||||
private final AccountLinkService accountLinkService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public ConnectController(
|
||||
ConnectRequestService service,
|
||||
LeaderTeamResolver leaderTeams,
|
||||
AccountLinkService accountLinkService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.service = service;
|
||||
this.leaderTeams = leaderTeams;
|
||||
this.accountLinkService = accountLinkService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/** Sent by the instance's own backend, before it holds any credential. */
|
||||
public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {}
|
||||
|
||||
/** {@code authorizeUrl} is where the instance should send its admin. */
|
||||
public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {}
|
||||
|
||||
/** What the approval page renders. */
|
||||
public record ViewResponse(
|
||||
String requestId,
|
||||
String name,
|
||||
String callbackOrigin,
|
||||
boolean insecureTransport,
|
||||
String mode,
|
||||
String status) {}
|
||||
|
||||
/** Where the approver's browser goes next, and the correlator the instance is waiting on. */
|
||||
public record ApproveResponse(String callbackUrl, String nonce) {}
|
||||
|
||||
public record ClaimBody(String requestId, String claimSecret) {}
|
||||
|
||||
public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {}
|
||||
|
||||
/** Opens a handshake. */
|
||||
@PostMapping("/request")
|
||||
public ResponseEntity<?> request(
|
||||
@RequestBody(required = false) CreateBody body, HttpServletRequest http) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
String deviceId = http.getHeader(HEADER_DEVICE_ID);
|
||||
String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET);
|
||||
boolean reauthRequested = deviceId != null || deviceSecret != null;
|
||||
|
||||
ConnectRequestService.CreateResult result;
|
||||
if (reauthRequested) {
|
||||
Long pinnedTeamId =
|
||||
accountLinkService
|
||||
.resolveActiveInstance(deviceId, deviceSecret)
|
||||
.map(LinkedInstance::getTeamId)
|
||||
.orElse(null);
|
||||
result =
|
||||
service.createReauth(
|
||||
body.name(),
|
||||
body.callbackUrl(),
|
||||
body.nonce(),
|
||||
body.claimSecret(),
|
||||
clientIp(http),
|
||||
pinnedTeamId);
|
||||
} else {
|
||||
result =
|
||||
service.create(
|
||||
body.name(),
|
||||
body.callbackUrl(),
|
||||
body.nonce(),
|
||||
body.claimSecret(),
|
||||
clientIp(http));
|
||||
}
|
||||
if (result.isRejected()) {
|
||||
return switch (result.rejection()) {
|
||||
case RATE_LIMITED ->
|
||||
ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "RATE_LIMITED"));
|
||||
case BAD_CALLBACK ->
|
||||
ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK"));
|
||||
case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE"));
|
||||
case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET"));
|
||||
// A credential was offered and did not authenticate. Same answer as any other bad
|
||||
// credential, and deliberately not distinguishable from "revoked".
|
||||
case NOT_LINKED ->
|
||||
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "NOT_LINKED"));
|
||||
};
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
new CreateResponse(
|
||||
result.requestId(),
|
||||
result.expiresInSeconds(),
|
||||
authorizeUrl(result.requestId(), http)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's
|
||||
* own base URL, including any base path; without it the API's origin has to serve the app too.
|
||||
*/
|
||||
private String authorizeUrl(String requestId, HttpServletRequest http) {
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
String base =
|
||||
frontendUrl != null && !frontendUrl.isBlank()
|
||||
? frontendUrl.strip().replaceAll("/+$", "")
|
||||
: requestOrigin(http);
|
||||
return base
|
||||
+ LINK_PATH
|
||||
+ "?request="
|
||||
+ URLEncoder.encode(requestId, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */
|
||||
private static String requestOrigin(HttpServletRequest request) {
|
||||
String proto = firstHop(request.getHeader("X-Forwarded-Proto"));
|
||||
String host = firstHop(request.getHeader("X-Forwarded-Host"));
|
||||
String scheme = proto != null ? proto : request.getScheme();
|
||||
// A forwarded host already carries its own port, if it needs one.
|
||||
String hostPort =
|
||||
host != null
|
||||
? host
|
||||
: Origins.hostPort(
|
||||
scheme, request.getServerName(), request.getServerPort());
|
||||
String context = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
return scheme + "://" + hostPort + context;
|
||||
}
|
||||
|
||||
private static String firstHop(String headerValue) {
|
||||
if (headerValue == null || headerValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String first = headerValue.split(",")[0].strip();
|
||||
return first.isEmpty() ? null : first;
|
||||
}
|
||||
|
||||
/** Detail for the approval page. */
|
||||
@GetMapping("/{requestId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<ViewResponse> view(@PathVariable String requestId) {
|
||||
return service.lookup(requestId)
|
||||
.map(
|
||||
v ->
|
||||
ResponseEntity.ok(
|
||||
new ViewResponse(
|
||||
v.requestId(),
|
||||
v.name(),
|
||||
v.callbackOrigin(),
|
||||
v.insecureTransport(),
|
||||
v.mode().name(),
|
||||
v.status().name())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/** Approves a handshake. */
|
||||
@PostMapping("/{requestId}/approve")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> approve(@PathVariable String requestId, Authentication auth) {
|
||||
Optional<ConnectRequestService.ConnectView> view = service.lookup(requestId);
|
||||
if (view.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH;
|
||||
LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth);
|
||||
if (lt.isError()) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
ConnectRequestService.ApproveResult result =
|
||||
service.approve(requestId, lt.teamId(), lt.userId());
|
||||
if (result.isRejected()) {
|
||||
return switch (result.rejection()) {
|
||||
// Named separately so the page can say "you are signed in to a different account"
|
||||
// rather than implying the request itself was bad.
|
||||
case WRONG_TEAM ->
|
||||
ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "WRONG_TEAM"));
|
||||
case UNAVAILABLE -> ResponseEntity.notFound().build();
|
||||
};
|
||||
}
|
||||
return ResponseEntity.ok(
|
||||
new ApproveResponse(result.target().callbackUrl(), result.target().nonce()));
|
||||
}
|
||||
|
||||
@PostMapping("/{requestId}/deny")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> deny(@PathVariable String requestId, Authentication auth) {
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.isError()) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
return service.deny(requestId)
|
||||
? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/** Collects the device credential. */
|
||||
@PostMapping("/claim")
|
||||
public ResponseEntity<?> claim(@RequestBody(required = false) ClaimBody body) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
ConnectRequestService.ClaimResult result =
|
||||
service.claim(body.requestId(), body.claimSecret());
|
||||
return switch (result.outcome()) {
|
||||
case GRANTED ->
|
||||
ResponseEntity.ok(
|
||||
new ClaimResponse(
|
||||
result.deviceId(), result.deviceSecret(), result.teamId()));
|
||||
// A re-authentication carries no credential: the instance already has one. It only
|
||||
// needs to know the browser leg succeeded, and which team it was confirmed against.
|
||||
case CONFIRMED ->
|
||||
ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId()));
|
||||
case PENDING ->
|
||||
ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending"));
|
||||
case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED"));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source address for the creation cap.
|
||||
*
|
||||
* <p>Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on
|
||||
* it lets one rotate fake addresses and have no cap at all. {@code
|
||||
* server.forward-headers-strategy} is NATIVE, so the container has already resolved the real
|
||||
* client from trusted proxies.
|
||||
*/
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** One in-flight "connect this server" handshake. Short lived and single use. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_connect_request",
|
||||
indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at"))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ConnectRequest {
|
||||
|
||||
public enum Mode {
|
||||
LINK,
|
||||
REAUTH
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
DENIED,
|
||||
CONSUMED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_id", nullable = false, unique = true, length = 64)
|
||||
private String requestId;
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Read back from here on approval, never from the request: that is what stops an open redirect.
|
||||
*/
|
||||
@Column(name = "callback_url", nullable = false, length = 2048)
|
||||
private String callbackUrl;
|
||||
|
||||
@Column(name = "callback_origin", nullable = false, length = 255)
|
||||
private String callbackOrigin;
|
||||
|
||||
@Column(name = "nonce", nullable = false, length = 128)
|
||||
private String nonce;
|
||||
|
||||
/** SHA-256; the secret itself is never stored. */
|
||||
@Column(name = "claim_secret_hash", nullable = false, length = 64)
|
||||
private String claimSecretHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "mode", nullable = false, length = 16)
|
||||
private Mode mode = Mode.LINK;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private Status status = Status.PENDING;
|
||||
|
||||
/** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "approved_by_user_id")
|
||||
private Long approvedByUserId;
|
||||
|
||||
@Column(name = "requester_ip", length = 45)
|
||||
private String requesterIp;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "approved_at")
|
||||
private LocalDateTime approvedAt;
|
||||
|
||||
@Column(name = "consumed_at")
|
||||
private LocalDateTime consumedAt;
|
||||
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return expiresAt != null && expiresAt.isBefore(now);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Removes connect requests that are past use.
|
||||
*
|
||||
* <p>Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and
|
||||
* nothing else deletes one. Requests hold a callback URL and the requester's address, so they are
|
||||
* swept soon after expiry rather than kept.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
@RequiredArgsConstructor
|
||||
public class ConnectRequestCleanupService {
|
||||
|
||||
/** Long enough to answer "what happened to my link?" the next morning, and no longer. */
|
||||
private static final int RETAIN_HOURS = 24;
|
||||
|
||||
private final ConnectRequestRepository repo;
|
||||
|
||||
@Scheduled(cron = "0 30 3 * * *")
|
||||
@Transactional
|
||||
public void purgeExpired() {
|
||||
try {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS);
|
||||
int deleted = repo.deleteByExpiresAtBefore(cutoff);
|
||||
if (deleted > 0) {
|
||||
log.info("Account-link connect: purged {} expired requests", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// A failed sweep must not take the scheduler down; the next run retries.
|
||||
log.error("Account-link connect: purge failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user