Compare commits
223
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd999144f0 | ||
|
|
f4ab39600d | ||
|
|
f703a67817 | ||
|
|
105af51100 | ||
|
|
57bf17d348 | ||
|
|
11df30b914 | ||
|
|
43162c40ad | ||
|
|
be57f11747 | ||
|
|
8ba8f69252 | ||
|
|
be97268a7c | ||
|
|
7bd3826178 | ||
|
|
17aa71850c | ||
|
|
20204f0ddc | ||
|
|
1df6a1759c | ||
|
|
b4f7b1d8a9 | ||
|
|
f881828cd8 | ||
|
|
16cfbc170e | ||
|
|
355d736487 | ||
|
|
f201aa5915 | ||
|
|
b69b787d63 | ||
|
|
e630d6697b | ||
|
|
a15e8227b4 | ||
|
|
12563050a6 | ||
|
|
1abd23cf94 | ||
|
|
11ba3814e5 | ||
|
|
675afe9b71 | ||
|
|
6c85200eb9 | ||
|
|
467f3a86c4 | ||
|
|
9d3701a585 | ||
|
|
c22ecc6c09 | ||
|
|
b38c849726 | ||
|
|
41f1cb2c22 | ||
|
|
ff3e3bd0fc | ||
|
|
54042c8e5e | ||
|
|
bb92ecc143 | ||
|
|
7ab30d2629 | ||
|
|
276eb8f2a7 | ||
|
|
e44da5c410 | ||
|
|
0beff1a92b | ||
|
|
425b76e9a7 | ||
|
|
c8af6e3b7e | ||
|
|
82ec2acaba | ||
|
|
5e97746721 | ||
|
|
14245d33d1 | ||
|
|
84739e8b0e | ||
|
|
0996277c41 | ||
|
|
d508bc41bf | ||
|
|
6ff910f26c | ||
|
|
013f145462 | ||
|
|
eea9696bd4 | ||
|
|
3f7e898c69 | ||
|
|
def3cf79f6 | ||
|
|
501a7199e0 | ||
|
|
bc6f1a1ff5 | ||
|
|
d06d3cabaf | ||
|
|
b040277220 | ||
|
|
f715a73f1b | ||
|
|
5be9a0e1df | ||
|
|
f7f7b8790e | ||
|
|
26021425e3 | ||
|
|
e35594f946 | ||
|
|
8a0b12b5ab | ||
|
|
60fff188a6 | ||
|
|
41181c9da1 | ||
|
|
8e485801c9 | ||
|
|
436afa51d7 | ||
|
|
0a29186ed6 | ||
|
|
101502cf4f | ||
|
|
f2b65f4a77 | ||
|
|
1816bad1ba | ||
|
|
9aee85d55e | ||
|
|
72f8705460 | ||
|
|
dffc292888 | ||
|
|
c95fb89c63 | ||
|
|
956b8000e4 | ||
|
|
a3fe15bfd0 | ||
|
|
1a770af47c | ||
|
|
3870ac3d7d | ||
|
|
b9ea9064c7 | ||
|
|
fe7a2a5ac7 | ||
|
|
6a9876a067 | ||
|
|
3793a6df52 | ||
|
|
66841db2b7 | ||
|
|
377677c182 | ||
|
|
f25f7e5fc9 | ||
|
|
b57958531d | ||
|
|
f8ceca0c3f | ||
|
|
e6d476297d | ||
|
|
3456316569 | ||
|
|
215bba39bc | ||
|
|
900b66b030 | ||
|
|
c3795c1a3c | ||
|
|
c8925acee7 | ||
|
|
eb08e60d67 | ||
|
|
18da914bf9 | ||
|
|
8f46ca0d92 | ||
|
|
9a3bc6b47f | ||
|
|
2b05865a84 | ||
|
|
0c503cc41d | ||
|
|
b1fef4c647 | ||
|
|
06254853af | ||
|
|
d9e6041a75 | ||
|
|
8f81fdc762 | ||
|
|
13af10a6d1 | ||
|
|
3750111ffc | ||
|
|
20c88feabb | ||
|
|
4f26fdeb5c | ||
|
|
df9dbc5179 | ||
|
|
de9242c4f7 | ||
|
|
460c037bbb | ||
|
|
cd7264a76a | ||
|
|
ef0deef4f2 | ||
|
|
65fcc036fe | ||
|
|
f127d4f575 | ||
|
|
f33f4f8f75 | ||
|
|
7e67bfc459 | ||
|
|
686fb1fb50 | ||
|
|
5389e39cfc | ||
|
|
ddf78d11ae | ||
|
|
10b4551449 | ||
|
|
96accea984 | ||
|
|
9a883be697 | ||
|
|
6716398ccb | ||
|
|
5b20257dea | ||
|
|
2f5fc7be4e | ||
|
|
d18caf6116 | ||
|
|
3bafbb1919 | ||
|
|
42c1cce56d | ||
|
|
fb6a118be9 | ||
|
|
c55beacead | ||
|
|
04d68c650a | ||
|
|
9d7467cf90 | ||
|
|
2a905c01c3 | ||
|
|
d6a5777c69 | ||
|
|
c1a637d764 | ||
|
|
085ad6c784 | ||
|
|
cb13102117 | ||
|
|
1fa1293b39 | ||
|
|
1ce765ab1e | ||
|
|
dad2425c27 | ||
|
|
eefa8eff61 | ||
|
|
63ecbe3b6d | ||
|
|
f1ed850a73 | ||
|
|
b11c272e87 | ||
|
|
f5e697347b | ||
|
|
4e880c7510 | ||
|
|
511b92b321 | ||
|
|
87723d3ce2 | ||
|
|
eb2527fc7f | ||
|
|
d363a1e957 | ||
|
|
ea102cdb93 | ||
|
|
d995471a55 | ||
|
|
e3e49c07ae | ||
|
|
962119e14f | ||
|
|
cc1235bbf2 | ||
|
|
e88d22d2fc | ||
|
|
ddf10f0aaf | ||
|
|
b756b5befb | ||
|
|
eddc54c6c0 | ||
|
|
22379fd5ab | ||
|
|
6f1c19c179 | ||
|
|
ef65e6b015 | ||
|
|
47e5977a31 | ||
|
|
3a4b340313 | ||
|
|
41d2aa8174 | ||
|
|
ee9fdeed6b | ||
|
|
b1a960a240 | ||
|
|
946c032fb5 | ||
|
|
7e493226c4 | ||
|
|
d48017a5b5 | ||
|
|
37b4d24a95 | ||
|
|
aaa2599e23 | ||
|
|
33026e1a82 | ||
|
|
1d598d5caa | ||
|
|
9e5fe2f4ca | ||
|
|
9ee0bc4b32 | ||
|
|
5fa5e12c64 | ||
|
|
34ead60194 | ||
|
|
5bc7ae626d | ||
|
|
f16ca4795c | ||
|
|
d52c7ced7c | ||
|
|
606964ee52 | ||
|
|
cf513c255b | ||
|
|
88adb7adad | ||
|
|
11ab762f57 | ||
|
|
68e031ac55 | ||
|
|
c722b9f6ad | ||
|
|
36c68fb69e | ||
|
|
d3c359f923 | ||
|
|
4947ab12fd | ||
|
|
8dde4262ec | ||
|
|
ebc28b0a14 | ||
|
|
56862cc1d3 | ||
|
|
d6306f51e1 | ||
|
|
9a1804ce04 | ||
|
|
be0db3fd8a | ||
|
|
90bda6b4b4 | ||
|
|
5b412c0fed | ||
|
|
bf18af4708 | ||
|
|
d29059e6fb | ||
|
|
e7bbbb4702 | ||
|
|
247ef6313c | ||
|
|
7f7c865888 | ||
|
|
2101b4028c | ||
|
|
06476ea69e | ||
|
|
1135bd9b63 | ||
|
|
bbfe29c2ef | ||
|
|
92376b7382 | ||
|
|
1d5ce8a1d2 | ||
|
|
8b2baaf0a0 | ||
|
|
d9651f7065 | ||
|
|
4cd03be87a | ||
|
|
02d923f378 | ||
|
|
e7d3430134 | ||
|
|
4b2be58fab | ||
|
|
290c8c2c8b | ||
|
|
90d6ecd7e1 | ||
|
|
a0b7daca52 | ||
|
|
0b575ed841 | ||
|
|
940cb2fc44 | ||
|
|
9da0a0d020 | ||
|
|
0b944a29a7 | ||
|
|
58aeba2bf7 |
@@ -20,8 +20,8 @@ set -e
|
||||
# - To build the project, use:
|
||||
# ./gradlew build
|
||||
#
|
||||
# - For running pre-commit hooks (if configured), use:
|
||||
# pre-commit run --all-files
|
||||
# - To run the lint/format/secret checks, use:
|
||||
# task pre-commit
|
||||
#
|
||||
# Make sure you are in the project root directory after this script executes.
|
||||
# =============================================================================
|
||||
@@ -70,6 +70,6 @@ echo ""
|
||||
echo " To build the project: "
|
||||
echo -e "\e[34m gradle build\e[0m"
|
||||
echo ""
|
||||
echo " To run pre-commit hooks (if configured):"
|
||||
echo -e "\e[34m pre-commit run --all-files -c .pre-commit-config.yaml\e[0m"
|
||||
echo " To run the lint/format/secret checks:"
|
||||
echo -e "\e[34m task pre-commit\e[0m"
|
||||
echo "=================================================================="
|
||||
|
||||
@@ -27,7 +27,6 @@ node_modules/
|
||||
**/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/editor/dist/
|
||||
frontend/dist-portal/
|
||||
frontend/editor/playwright-report/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.12.0
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.12.0
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
@@ -38,6 +40,9 @@ project: &project
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
- .taskfiles/docker.yml
|
||||
- scripts/db-migration/**
|
||||
- .github/workflows/db-migration-test.yml
|
||||
|
||||
@@ -55,6 +60,9 @@ frontend: &frontend
|
||||
- scripts/summarize_type3_signatures.py
|
||||
- scripts/type3_to_cff.py
|
||||
- scripts/update_type3_library.py
|
||||
- Taskfile.yml
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/e2e.yml
|
||||
|
||||
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
|
||||
# job on changes to any of these.
|
||||
@@ -66,6 +74,8 @@ tauri: &tauri
|
||||
- frontend/package-lock.json
|
||||
- frontend/editor/vite.config.ts
|
||||
- .github/workflows/tauri-build.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/desktop.yml
|
||||
|
||||
# Files that affect the AI engine (Python tool models, fixers, tests). Gate
|
||||
# the engine validation job on changes to engine sources or to the Java
|
||||
@@ -74,6 +84,23 @@ engine: &engine
|
||||
- engine/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
# Files that can make the committed generated API models (frontend tool API
|
||||
# types + engine tool models) go stale: the Java tool surfaces they derive from,
|
||||
# the generators, the generated files themselves (to catch a hand-edit), and the
|
||||
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
|
||||
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
|
||||
generated-models: &generated-models
|
||||
- *openapi
|
||||
- frontend/editor/scripts/generate-tool-api-types.mts
|
||||
- frontend/editor/src/core/types/toolApiTypes.ts
|
||||
- engine/scripts/generate_tool_models.py
|
||||
- engine/src/stirling/models/tool_models.py
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/engine.yml
|
||||
- .github/workflows/check-generated-models.yml
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
@@ -102,4 +129,4 @@ proprietary: &proprietary
|
||||
- configs/settings.yml.template
|
||||
- build.gradle
|
||||
- app/proprietary/build.gradle
|
||||
- .github/workflows/build-enterprise.yml
|
||||
- .github/workflows/build-enterprise.yml
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
"""
|
||||
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-GB/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
@@ -211,7 +211,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
)
|
||||
continue
|
||||
|
||||
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
|
||||
if basename_current_file == basename_reference_file and locale_dir == "en-US":
|
||||
continue
|
||||
|
||||
if (
|
||||
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)"
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pre-commit
|
||||
@@ -1,121 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.29.0 \
|
||||
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
|
||||
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
identify==2.6.19 \
|
||||
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
|
||||
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
|
||||
# via pre-commit
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
platformdirs==4.9.6 \
|
||||
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
|
||||
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
pre-commit==4.6.0 \
|
||||
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
|
||||
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
python-discovery==1.2.2 \
|
||||
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
|
||||
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
|
||||
# via virtualenv
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
virtualenv==21.2.4 \
|
||||
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
|
||||
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
|
||||
# via pre-commit
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
|
||||
- name: Build and push V2 image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture"
|
||||
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
|
||||
@@ -222,10 +222,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
|
||||
- name: Build and push PR-specific image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
|
||||
- name: Build and push engine image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ./engine
|
||||
@@ -388,7 +388,7 @@ jobs:
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}"
|
||||
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF PR#${PR_NUMBER}"
|
||||
UI_HOMEDESCRIPTION: "PR#${PR_NUMBER} for Stirling-PDF Latest"
|
||||
UI_APPNAMENAVBAR: "PR#${PR_NUMBER}"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# Validates the Python AI engine: regenerates tool models and runs the
|
||||
# engine quality gate (lint, type-check, format-check, tests). Called from
|
||||
# build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net.
|
||||
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
|
||||
# from build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net. Freshness of the generated tool_models.py is checked
|
||||
# by the shared check-generated-models workflow.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -30,107 +30,12 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Regenerate tool models
|
||||
run: task engine:tool-models
|
||||
|
||||
- name: Verify tool models are up to date
|
||||
id: tool-models-check
|
||||
continue-on-error: true
|
||||
run: git diff --exit-code engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on tool models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Tool Models Check Failed',
|
||||
'',
|
||||
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
|
||||
'',
|
||||
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if tool models check failed
|
||||
if: steps.tool-models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Tool Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated engine/src/stirling/models/tool_models.py"
|
||||
echo "is out of date with the Java OpenAPI spec and will"
|
||||
echo "need to be regenerated before it can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:tool-models' to regenerate, then"
|
||||
echo "commit the updated file."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove tool models check comment on success
|
||||
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
# Runs once per matrix combination - pick the cheapest leg
|
||||
# (core - no proprietary, no saas) so we don't wait for the
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
# Enterprise Playwright suite — exercises premium-key gated features (audit,
|
||||
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in three
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in four
|
||||
# situations:
|
||||
#
|
||||
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
|
||||
@@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright)
|
||||
# - on a nightly cron schedule (catches Keycloak image drift, license
|
||||
# expiry, upstream proprietary changes),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -52,6 +50,10 @@ jobs:
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
|
||||
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
|
||||
# header comment. GitHub reports the skipped reusable workflow as success.
|
||||
if: needs.pick.outputs.is_fork != 'true'
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -78,7 +80,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
@@ -165,6 +167,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise OAuth Playwright tests
|
||||
id: oauth-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
|
||||
run: task e2e:enterprise -- --grep "OAuth"
|
||||
- name: Stop backend + tear down OAuth Keycloak
|
||||
if: always()
|
||||
@@ -238,6 +242,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise SAML Playwright tests
|
||||
id: saml-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
|
||||
run: task e2e:enterprise -- --grep "SAML"
|
||||
- name: Stop backend + tear down SAML Keycloak
|
||||
if: always()
|
||||
@@ -268,6 +274,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise feature Playwright tests
|
||||
id: feature-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
|
||||
run: task e2e:enterprise -- --grep "Enterprise license"
|
||||
- name: Print backend log on failure
|
||||
if: failure()
|
||||
@@ -280,10 +288,23 @@ jobs:
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcomes: a flaky test (passed on retry)
|
||||
# leaves its step green, so this is the only place it surfaces. Merges
|
||||
# all three phase reports (some may be absent if an earlier phase hard-
|
||||
# failed and skipped the rest). Emits ::warning:: annotations + a job
|
||||
# summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: >
|
||||
npx tsx editor/scripts/report-flaky-tests.mts
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
tauri: ${{ steps.changes.outputs.tauri }}
|
||||
engine: ${{ steps.changes.outputs.engine }}
|
||||
generated-models: ${{ steps.changes.outputs.generated-models }}
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
@@ -171,6 +172,20 @@ jobs:
|
||||
uses: ./.github/workflows/ai-engine.yml
|
||||
secrets: inherit
|
||||
|
||||
# The generated frontend types and engine tool models are both derived from
|
||||
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
|
||||
# backend, so it is gated on the narrow generated-models filter (spec source,
|
||||
# generators, generated files, generation tasks) rather than the broad
|
||||
# frontend filter, so a CSS-only PR does not pay for a backend build.
|
||||
generated-models:
|
||||
if: needs.files-changed.outputs.generated-models == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/check-generated-models.yml
|
||||
secrets: inherit
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
@@ -202,6 +217,9 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/coverage-aggregate.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
frontend-validation-result: ${{ needs.frontend-validation.result }}
|
||||
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
|
||||
|
||||
# Single status check that branch protection should mark as required.
|
||||
# Succeeds when every upstream job is either `success` or `skipped` (path-
|
||||
@@ -225,6 +243,7 @@ jobs:
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- generated-models
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -250,6 +269,7 @@ jobs:
|
||||
test-build-docker-images=${{ needs.test-build-docker-images.result }}
|
||||
tauri-build=${{ needs.tauri-build.result }}
|
||||
ai-engine=${{ needs.ai-engine.result }}
|
||||
generated-models=${{ needs.generated-models.result }}
|
||||
pre-commit=${{ needs.pre-commit.result }}
|
||||
dependency-review=${{ needs.dependency-review.result }}
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
name: Check generated models
|
||||
|
||||
# Verifies the committed generated API models are still in sync with the Java
|
||||
# OpenAPI spec: the frontend tool API types
|
||||
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
|
||||
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
|
||||
# single top-level `task tool-models` and fails if either committed file is
|
||||
# out of date. Called from build.yml when the backend Java, frontend, or engine
|
||||
# changes; also runs on push to main as a post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generated-models:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
|
||||
# frontend types and the engine tool models from it.
|
||||
- name: Regenerate generated models
|
||||
run: task tool-models
|
||||
|
||||
- name: Verify generated models are up to date
|
||||
id: models-check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git diff --exit-code \
|
||||
frontend/editor/src/core/types/toolApiTypes.ts \
|
||||
engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on generated models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- generated-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Generated Models Check Failed',
|
||||
'',
|
||||
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
|
||||
'',
|
||||
'Run `task tool-models` to regenerate both, then commit the updated files.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if generated models check failed
|
||||
if: steps.models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Generated Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated frontend API types and/or engine tool"
|
||||
echo "models are out of date with the Java OpenAPI spec and"
|
||||
echo "will need to be regenerated before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task tool-models' to regenerate both, then"
|
||||
echo "commit the updated files."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove generated models check comment on success
|
||||
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- generated-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
|
||||
@@ -45,11 +45,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
|
||||
@@ -166,16 +166,16 @@ jobs:
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("frontend/editor/public/locales/en-GB/translation.toml")) {
|
||||
if (changedFiles.includes("frontend/editor/public/locales/en-US/translation.toml")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "frontend/editor/public/locales/en-GB/translation.toml",
|
||||
path: "frontend/editor/public/locales/en-US/translation.toml",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
referenceFilePath = "pr-branch-translation-en-GB.toml";
|
||||
referenceFilePath = "pr-branch-translation-en-US.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
} else {
|
||||
@@ -183,11 +183,11 @@ jobs:
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "frontend/editor/public/locales/en-GB/translation.toml",
|
||||
path: "frontend/editor/public/locales/en-US/translation.toml",
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
referenceFilePath = "main-branch-translation-en-GB.toml";
|
||||
referenceFilePath = "main-branch-translation-en-US.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
}
|
||||
@@ -293,6 +293,6 @@ jobs:
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf pr-branch
|
||||
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
|
||||
rm -f pr-branch-translation-en-US.toml main-branch-translation-en-US.toml changed_files.txt result.txt
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
|
||||
@@ -13,6 +13,17 @@ name: Aggregate backend coverage
|
||||
# producers themselves
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
frontend-validation-result:
|
||||
description: Result of the frontend-validation producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
playwright-e2e-live-result:
|
||||
description: Result of the playwright-e2e-live producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -51,7 +62,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Python
|
||||
@@ -196,9 +207,9 @@ jobs:
|
||||
# --------------------------------------------------------------
|
||||
- name: Download vitest coverage artifact
|
||||
# frontend-validation uploads as `frontend-coverage`. Tolerate
|
||||
# absence so a backend-only PR still produces the matrix with
|
||||
# just backend rows populated.
|
||||
if: always()
|
||||
# absence on backend-only runs by skipping the download entirely
|
||||
# when the producer job was not part of this workflow run.
|
||||
if: inputs.frontend-validation-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: frontend-coverage
|
||||
@@ -206,12 +217,12 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Download Playwright frontend coverage artifact
|
||||
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
|
||||
# Same tolerance as vitest - matrix script handles missing inputs.
|
||||
if: always()
|
||||
# e2e-live uploads the artifact with a stable name. Skip the
|
||||
# download entirely when the producer job did not run.
|
||||
if: inputs.playwright-e2e-live-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: matrix-inputs/playwright/
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Build and push frontend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
|
||||
- name: Build and push backend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF V2"
|
||||
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
|
||||
UI_APPNAMENAVBAR: "V2 Deployment"
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
name: Docker Compose Cucumber tests (saas / PAYG)
|
||||
|
||||
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
|
||||
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
|
||||
# that doesn't touch the saas flavour.
|
||||
#
|
||||
# Companion to `docker-compose-tests.yml` (which runs against the
|
||||
# proprietary-flavour stack and skips features/payg via behave.ini's
|
||||
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
|
||||
# succeed independently without touching the main cucumber harness.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "app/saas/**"
|
||||
- "testing/cucumber/features/payg/**"
|
||||
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||
- "testing/cucumber/requirements.txt"
|
||||
- "testing/compose/docker-compose-saas.yml"
|
||||
- "testing/compose/payg/**"
|
||||
- "testing/test-payg.sh"
|
||||
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/saas/**"
|
||||
- "testing/cucumber/features/payg/**"
|
||||
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||
- "testing/cucumber/requirements.txt"
|
||||
- "testing/compose/docker-compose-saas.yml"
|
||||
- "testing/compose/payg/**"
|
||||
- "testing/test-payg.sh"
|
||||
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
docker-compose-tests-saas:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
|
||||
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
|
||||
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
|
||||
# isn't needed. Avoids a `curl | sudo install` without checksum verification
|
||||
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Run PAYG Cucumber Tests
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: |
|
||||
chmod +x ./testing/test-payg.sh
|
||||
./testing/test-payg.sh
|
||||
|
||||
- name: Dump saas container logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
|
||||
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
|
||||
|
||||
- name: Upload PAYG Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: payg-cucumber-report
|
||||
path: testing/cucumber/report-payg.html
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: PAYG Cucumber Test Report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: PAYG Cucumber Tests
|
||||
path: testing/cucumber/junit-payg/*.xml
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
@@ -61,14 +61,22 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
# container builder can't see that store, so skip it here and let
|
||||
# `docker buildx build` fall back to the default docker driver, which
|
||||
# resolves the local base. The gha cache backend is also skipped (its
|
||||
# runtime token isn't exposed) since the docker driver can't use it.
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
@@ -62,7 +62,17 @@ jobs:
|
||||
# .test-state/playwright/coverage-pw/ for the post-process step
|
||||
# to aggregate. Chromium-only - other engines silently skip.
|
||||
PW_COVERAGE: "1"
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:live
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Generate JaCoCo report from e2e:live .exec
|
||||
if: always()
|
||||
id: live-coverage
|
||||
@@ -169,7 +179,7 @@ jobs:
|
||||
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: |
|
||||
.test-state/playwright/coverage-pw-summary/
|
||||
.test-state/playwright/coverage-pw/
|
||||
@@ -188,10 +198,30 @@ jobs:
|
||||
name: backend-log-live-${{ github.run_id }}
|
||||
path: .test-state/playwright/backend.log
|
||||
retention-days: 7
|
||||
- name: Upload Playwright report
|
||||
- name: List Playwright output locations (debug)
|
||||
if: always()
|
||||
run: |
|
||||
echo "::group::Playwright output dirs"
|
||||
# Playwright anchors its default outputDir + HTML report to the
|
||||
# nearest package.json, which is frontend/ (frontend/editor has
|
||||
# none), so artifacts land under frontend/, not frontend/editor/.
|
||||
ls -la frontend/playwright-report 2>/dev/null \
|
||||
|| echo "no playwright-report at frontend/"
|
||||
ls -la frontend/test-results 2>/dev/null \
|
||||
|| echo "no test-results at frontend/"
|
||||
find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true
|
||||
echo "::endgroup::"
|
||||
- name: Upload Playwright report + traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-live-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
# test-results/ holds the per-test trace.zip (with browser console
|
||||
# logs) + screenshots/video; playwright-report/ is the HTML report.
|
||||
# Both live under frontend/ (Playwright anchors them to the nearest
|
||||
# package.json, which is frontend/; frontend/editor has none).
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
@@ -44,11 +44,22 @@ jobs:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -97,7 +97,14 @@ jobs:
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: task frontend:licenses:generate
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
env:
|
||||
@@ -349,10 +356,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -252,10 +252,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
|
||||
@@ -37,10 +37,15 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
|
||||
- name: Run E2E tests (all browsers)
|
||||
run: task e2e:cross-browser
|
||||
|
||||
@@ -48,6 +53,19 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
name: playwright-report-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
# written on main, where PR and merge-queue tauri builds can restore it.
|
||||
warm-tauri-cache:
|
||||
name: Warm Tauri Rust cache
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
with:
|
||||
platform: all
|
||||
sign: false
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Pre-commit
|
||||
|
||||
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via
|
||||
# workflow_dispatch for manual local-equivalent linting.
|
||||
# Runs the repo-wide lint/format/secret checks via `task pre-commit`.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via workflow_dispatch.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
@@ -13,10 +12,6 @@ permissions:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Prevents sdist builds → no tar extraction
|
||||
PIP_ONLY_BINARY: ":all:"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -29,23 +24,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
|
||||
enable-cache: true
|
||||
|
||||
- name: Run Pre-Commit Hooks
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Run Pre-Commit
|
||||
run: |
|
||||
pre-commit run ruff --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run codespell --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
git diff --exit-code
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
|
||||
|
||||
@@ -78,14 +78,14 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
if: env.RUN_MAIN_APP == 'true'
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -155,9 +155,9 @@ jobs:
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
|
||||
build-args: |
|
||||
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
BASE_VERSION=1.0.0
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
SWAGGERHUB_USER: "Frooodle"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -58,15 +58,23 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-GB/translation.toml" --branch main
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
|
||||
|
||||
- name: pre-commit run
|
||||
- name: Sort translation TOML files
|
||||
run: |
|
||||
pre-commit run toml-sort-fix --all-files
|
||||
task pre-commit:toml-sort FIX=1
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
@@ -100,7 +108,7 @@ jobs:
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
|
||||
|
||||
#### **1. Synchronization of Translation Files**
|
||||
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
|
||||
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
|
||||
- Ensured consistency and synchronization across all supported language files.
|
||||
- Highlighted any missing or incomplete translations.
|
||||
- **Format**: TOML
|
||||
|
||||
@@ -16,6 +16,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -28,6 +33,11 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -115,6 +125,20 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# Cache the Cargo registry and compiled dependency crates so the build
|
||||
# only recompiles the app crate. Written on main; PRs and the merge queue
|
||||
# restore from it.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
# Stable key shared across workflows so the nightly warmer.
|
||||
# rust-cache still appends OS + rustc + Cargo.lock.
|
||||
shared-key: tauri-${{ matrix.name }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -136,10 +160,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
@@ -163,7 +187,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -173,7 +197,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -208,7 +232,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -239,7 +263,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -260,7 +284,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -283,7 +307,7 @@ jobs:
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Preflight smctl
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -296,7 +320,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
|
||||
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -315,7 +339,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
@@ -332,7 +356,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
- name: Build Tauri app (signed)
|
||||
if: inputs.sign
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -366,6 +391,26 @@ jobs:
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: ${{ !inputs.sign }}
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: "0"
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here. AppImage runs in its own
|
||||
# continue-on-error step below so its persistent linuxdeploy
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
|
||||
# AppImage is decoupled so its linuxdeploy run gets a fresh process
|
||||
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
@@ -374,7 +419,7 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -389,7 +434,7 @@ jobs:
|
||||
args: --bundles appimage
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
run: |
|
||||
@@ -399,7 +444,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15'
|
||||
if: inputs.sign && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
@@ -437,7 +482,7 @@ jobs:
|
||||
# Verify the MSI AND the inner exe extracted from it are signed.
|
||||
# The inner exe is what gets installed on users' machines and what AV scans.
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
|
||||
@@ -106,11 +106,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Build application
|
||||
run: task backend:build
|
||||
env:
|
||||
@@ -155,9 +155,22 @@ jobs:
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Base-changed PRs build the embedded image with the local docker driver
|
||||
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
|
||||
# store) resolves. A buildx container builder cannot see it and would try
|
||||
# to pull it from a registry, which fails. Single-platform, no gha cache.
|
||||
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
|
||||
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
|
||||
run: |
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
|
||||
--file ./${{ matrix.docker-rev }} \
|
||||
--tag stirling-pdf-embedded:pr-test \
|
||||
.
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }} (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -169,8 +182,10 @@ jobs:
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Fork PRs that did NOT change the base use the buildx container builder
|
||||
# (multi-platform + gha cache) against the published base image.
|
||||
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
@@ -230,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Build and push test image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF Test"
|
||||
UI_HOMEDESCRIPTION: "Test Deployment"
|
||||
UI_APPNAMENAVBAR: "Test"
|
||||
|
||||
@@ -46,6 +46,12 @@ app/core/storage/
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
!app/core/src/main/resources/static/mobile-upload.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
@@ -53,6 +59,8 @@ app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/og-metadata.json
|
||||
app/core/src/main/resources/static/sw-folder-retry.js
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/pdfjs/
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
# PostHog project-level key — phc_ prefix keys are public/client-side by design
|
||||
# PostHog project-level key - phc_ prefix keys are public/client-side by design
|
||||
# (PostHog client-side tracking embeds them in the browser bundle). Committed
|
||||
# intentionally in #6150 so engine/.env has a working default, with real
|
||||
# credentials overridden via engine/.env.local.
|
||||
@@ -12,3 +12,18 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
|
||||
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
|
||||
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:116
|
||||
|
||||
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
|
||||
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
|
||||
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
.github/workflows/tauri-build.yml:generic-api-key:402
|
||||
|
||||
+12
-50
@@ -1,52 +1,14 @@
|
||||
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
|
||||
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
|
||||
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
|
||||
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
|
||||
# of only checking, run `task pre-commit:fix`.
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- --line-length=127
|
||||
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
|
||||
exclude: (split_photos.py)
|
||||
- id: ruff-format
|
||||
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
|
||||
exclude: (split_photos.py)
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- id: trailing-whitespace
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- repo: https://github.com/pappasam/toml-sort
|
||||
rev: v0.24.4
|
||||
hooks:
|
||||
- id: toml-sort-fix
|
||||
files: frontend/editor/public/locales/.*\.toml$
|
||||
args: ['--in-place', '--all', '--ignore-case']
|
||||
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
|
||||
# rev: v16.21.1
|
||||
# hooks:
|
||||
# - id: stylelint
|
||||
# additional_dependencies:
|
||||
# - stylelint@16.21.1
|
||||
# - stylelint-config-standard@38.0.0
|
||||
# - "@stylistic/stylelint-plugin@3.1.3"
|
||||
# files: \.(css)$
|
||||
# args: [--fix]
|
||||
- id: task-pre-commit
|
||||
name: task pre-commit
|
||||
entry: task pre-commit
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
+27
-2
@@ -18,17 +18,36 @@ version: '3'
|
||||
tasks:
|
||||
dev:
|
||||
desc: "Start backend dev server"
|
||||
cmds:
|
||||
- task: dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||
# subtaskfile is included with `dir: .`. Local overrides in
|
||||
# .env.proprietary.local win over the committed .env.proprietary defaults.
|
||||
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{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 .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{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 .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -50,9 +69,15 @@ tasks:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
cmds:
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
||||
platforms: [windows]
|
||||
|
||||
+48
-8
@@ -5,6 +5,11 @@ vars:
|
||||
# NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf
|
||||
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink"
|
||||
|
||||
# Minimum Java major the bundled JRE must be. Keep in sync with build.gradle
|
||||
# `modernJavaVersion` - the app JAR is compiled for this, so an older runtime
|
||||
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
|
||||
REQUIRED_JAVA: "25"
|
||||
|
||||
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
|
||||
JPDFIUM_PLATFORMS:
|
||||
sh: |
|
||||
@@ -102,6 +107,20 @@ tasks:
|
||||
jlink:
|
||||
desc: "Build backend JAR and create JLink runtime for Tauri"
|
||||
deps: [jlink:jar, jlink:runtime]
|
||||
# Runs after the runtime is in place. Lives here (not in jlink:runtime's
|
||||
# cmds) so it still fires when jlink:runtime short-circuits on its `status:`
|
||||
# check and reuses an existing runtime/jre - that reuse path is exactly how
|
||||
# a stale, too-old JRE slips through.
|
||||
cmds:
|
||||
- task: jlink:verify
|
||||
|
||||
jlink:verify:
|
||||
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
|
||||
dir: editor
|
||||
env:
|
||||
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
|
||||
cmds:
|
||||
- node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release
|
||||
|
||||
jlink:jar:
|
||||
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
|
||||
@@ -127,14 +146,35 @@ tasks:
|
||||
cmds:
|
||||
- rm -rf runtime/jre
|
||||
- mkdir -p runtime
|
||||
- >-
|
||||
jlink
|
||||
--add-modules {{.JLINK_MODULES}}
|
||||
--strip-debug
|
||||
--compress=zip-6
|
||||
--no-header-files
|
||||
--no-man-pages
|
||||
--output runtime/jre
|
||||
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
|
||||
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
|
||||
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
|
||||
#
|
||||
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
|
||||
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
|
||||
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [windows]
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [linux, darwin]
|
||||
# jlink emits its files mode 444 (read-only). Tauri's build-script
|
||||
# resource copier preserves source permissions when staging
|
||||
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
|
||||
|
||||
@@ -20,6 +20,11 @@ tasks:
|
||||
cmds:
|
||||
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
|
||||
|
||||
build:backend:
|
||||
desc: "Build backend-only Docker image (no embedded frontend)"
|
||||
cmds:
|
||||
- docker build -t stirling-pdf-backend -f docker/backend/Dockerfile .
|
||||
|
||||
build:frontend:
|
||||
desc: "Build frontend-only Docker image"
|
||||
cmds:
|
||||
|
||||
+3
-16
@@ -1,12 +1,5 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
|
||||
# vars (Task merges included-file vars into the global scope).
|
||||
# Paths are relative to the engine/ include dir.
|
||||
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
|
||||
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install engine dependencies"
|
||||
@@ -36,14 +29,11 @@ tasks:
|
||||
ignore_error: true
|
||||
dir: src
|
||||
vars:
|
||||
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||
# When running standalone, probe for a free port starting at 5001.
|
||||
PORT:
|
||||
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||
PORT: '{{.PORT | default "5001"}}'
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
|
||||
|
||||
dev:
|
||||
desc: "Start engine dev server with hot reload"
|
||||
@@ -51,10 +41,7 @@ tasks:
|
||||
ignore_error: true
|
||||
dir: src
|
||||
vars:
|
||||
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||
# When running standalone, probe for a free port starting at 5001.
|
||||
PORT:
|
||||
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||
PORT: '{{.PORT | default "5001"}}'
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
|
||||
+92
-29
@@ -40,6 +40,21 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-icons.js
|
||||
|
||||
prepare:og:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate OG/social-preview metadata from the tool registry"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs
|
||||
sources:
|
||||
- editor/src/core/types/toolId.ts
|
||||
- editor/src/core/utils/urlMapping.ts
|
||||
- editor/src/core/data/useTranslatedToolRegistry.tsx
|
||||
- editor/public/og_images/*.png
|
||||
generates:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -49,6 +64,7 @@ tasks:
|
||||
- task: prepare:env
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -112,12 +128,6 @@ tasks:
|
||||
- task: dev:_run
|
||||
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
# ============================================================
|
||||
@@ -137,8 +147,10 @@ tasks:
|
||||
build:proprietary:
|
||||
desc: "Build for proprietary mode"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PREVIEW: '{{.PREVIEW | default ""}}'
|
||||
cmds:
|
||||
- npx vite build editor --mode proprietary
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
@@ -162,11 +174,6 @@ tasks:
|
||||
cmds:
|
||||
- npx vite build editor --mode prototypes
|
||||
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx vite build portal
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -202,8 +209,8 @@ tasks:
|
||||
deps: [install]
|
||||
cmds:
|
||||
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
|
||||
# shell-agnostic. Covers editor, portal, and the shared design system.
|
||||
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
|
||||
# shell-agnostic. Covers the whole editor tree, including the portal layer.
|
||||
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
|
||||
|
||||
lint:fix:
|
||||
desc: "Auto-fix lint issues"
|
||||
@@ -234,17 +241,26 @@ tasks:
|
||||
cmds:
|
||||
- task: typecheck:proprietary
|
||||
|
||||
typecheck:_run:
|
||||
internal: true
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
cmds:
|
||||
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
|
||||
|
||||
typecheck:core:
|
||||
desc: "Typecheck core build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/core/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/core/tsconfig.json }
|
||||
|
||||
typecheck:proprietary:
|
||||
desc: "Typecheck proprietary build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
|
||||
|
||||
typecheck:saas:
|
||||
desc: "Typecheck SaaS build variant"
|
||||
@@ -252,7 +268,8 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/saas/tsconfig.json }
|
||||
|
||||
typecheck:desktop:
|
||||
desc: "Typecheck desktop build variant"
|
||||
@@ -260,31 +277,36 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/desktop/tsconfig.json }
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/cloud/tsconfig.json }
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/scripts/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/scripts/tsconfig.json }
|
||||
|
||||
typecheck:prototypes:
|
||||
desc: "Typecheck prototypes build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
|
||||
|
||||
typecheck:portal:
|
||||
desc: "Typecheck developer portal build variant"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project portal/tsconfig.json
|
||||
|
||||
typecheck:shared:
|
||||
desc: "Typecheck the shared design system"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project shared/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/portal/tsconfig.json }
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
@@ -293,10 +315,10 @@ tasks:
|
||||
- task: typecheck:proprietary
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:cloud
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
- task: typecheck:shared
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -310,14 +332,21 @@ tasks:
|
||||
- task: format:check
|
||||
- task: test
|
||||
|
||||
og:check:
|
||||
desc: "Fail if committed OG/social-preview metadata is out of date"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
- task: build
|
||||
- task: build:portal
|
||||
- task: test
|
||||
- task: storybook:build
|
||||
|
||||
@@ -327,6 +356,11 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
@@ -362,8 +396,37 @@ tasks:
|
||||
# Code Generation
|
||||
# ============================================================
|
||||
|
||||
tool-models:
|
||||
desc: "Generate tool API types from the Java OpenAPI spec"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
|
||||
sources:
|
||||
- editor/scripts/generate-tool-api-types.mts
|
||||
- ../SwaggerDoc.json
|
||||
generates:
|
||||
- editor/src/core/types/toolApiTypes.ts
|
||||
|
||||
tool-models:check:
|
||||
desc: "Fail if committed tool API types are out of date"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
|
||||
|
||||
licenses:generate:
|
||||
desc: "Generate frontend license report"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/generate-licenses.js
|
||||
|
||||
# ============================================================
|
||||
# Clean
|
||||
# ============================================================
|
||||
|
||||
clean:
|
||||
desc: "Clean build artifacts and caches"
|
||||
cmds:
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
|
||||
platforms: [windows]
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist
|
||||
platforms: [linux, darwin]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
version: '3'
|
||||
|
||||
# Repo-wide lint/format/secret checks - the single source of truth that the git
|
||||
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
|
||||
|
||||
vars:
|
||||
# File selections as git pathspecs: git does the include/exclude matching, so
|
||||
# there is no grep/xargs and it behaves identically on every platform.
|
||||
PY_FILES: >-
|
||||
'scripts/*.py'
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
'*.js'
|
||||
'*.py'
|
||||
'*.md'
|
||||
':(exclude).vscode/*'
|
||||
':(exclude).devcontainer/*'
|
||||
':(exclude)app/core/src/main/resources/*'
|
||||
':(exclude)app/proprietary/src/main/resources/*'
|
||||
':(exclude)frontend/editor/public/vendor/*'
|
||||
':(exclude)*Dockerfile*'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
':(exclude)*bootstrap*'
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
WS_FILES: >-
|
||||
'*.js'
|
||||
'*.java'
|
||||
'*.py'
|
||||
'*.yml'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
':(exclude)*bootstrap*'
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
':(exclude).github/workflows/*'
|
||||
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
|
||||
|
||||
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
|
||||
# which owns the version and caches the binary here.
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: "Check formatting, spelling, and secrets across the repo"
|
||||
cmds:
|
||||
- task: ruff
|
||||
- task: ruff-format
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
cmds:
|
||||
# Auto-fixers first, then the report-only tools (codespell, gitleaks) so a
|
||||
# finding there does not stop the fixers from running.
|
||||
- task: ruff
|
||||
vars: { FIX: '1' }
|
||||
- task: ruff-format
|
||||
vars: { FIX: '1' }
|
||||
- task: whitespace
|
||||
vars: { FIX: '1' }
|
||||
- task: toml-sort
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
|
||||
run: once
|
||||
cmds:
|
||||
- uv sync --project scripts/pre-commit --locked
|
||||
sources:
|
||||
- scripts/pre-commit/uv.lock
|
||||
- scripts/pre-commit/pyproject.toml
|
||||
status:
|
||||
- test -d scripts/pre-commit/.venv
|
||||
|
||||
clean:
|
||||
desc: "Remove the cached gitleaks binary and the tool virtualenv"
|
||||
cmds:
|
||||
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
|
||||
platforms: [linux, darwin]
|
||||
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
|
||||
platforms: [windows]
|
||||
ignore_error: true
|
||||
|
||||
# Individual checks (hidden from `task --list`, but callable, e.g.
|
||||
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
|
||||
ruff:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
ruff-format:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
codespell:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
|
||||
|
||||
gitleaks:
|
||||
deps: [gitleaks-bin]
|
||||
# Scan staged changes only, matching the old hook: the git-mode fingerprints
|
||||
# in .gitleaksignore (file:rule:line) still apply, and with nothing staged
|
||||
# this is a no-op. Secrets are never auto-fixed, so FIX has no effect.
|
||||
cmds:
|
||||
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
|
||||
+3
-3
@@ -200,9 +200,9 @@ const [ToolName] = (props: BaseToolProps) => {
|
||||
```
|
||||
|
||||
## 5. Add Translations
|
||||
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
|
||||
Update translation files. **Important: Only update `en-US` files** - other languages are handled separately.
|
||||
|
||||
**File to update:** `frontend/editor/public/locales/en-GB/translation.toml`
|
||||
**File to update:** `frontend/editor/public/locales/en-US/translation.toml`
|
||||
|
||||
**Required Translation Keys**:
|
||||
```toml
|
||||
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
|
||||
```
|
||||
|
||||
**Translation Notes:**
|
||||
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
|
||||
- **Only update `en-US/translation.toml`** - other locale files are managed separately
|
||||
- Use descriptive keys that match your component's `t()` calls
|
||||
- Include tooltip translations if you created tooltip hooks
|
||||
- Add `options.*` keys if your tool has settings with descriptions
|
||||
|
||||
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
|
||||
#### Environment Variables
|
||||
- All `VITE_*` variables must be declared in the appropriate committed env file:
|
||||
- `frontend/editor/.env` — core, proprietary, and shared vars
|
||||
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
|
||||
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
|
||||
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
|
||||
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
|
||||
- These files are committed to Git and must not contain private keys
|
||||
@@ -152,7 +153,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
@@ -169,7 +170,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
|
||||
|
||||
#### Frontend `cloud/` Layer
|
||||
|
||||
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
|
||||
|
||||
- **core** → core
|
||||
- **proprietary** → proprietary → core
|
||||
- **saas** → saas → cloud → proprietary → core
|
||||
- **desktop** → desktop → cloud → proprietary → core
|
||||
- **cloud** → cloud → proprietary → core
|
||||
|
||||
What goes where:
|
||||
|
||||
- **core** — OSS base.
|
||||
- **proprietary** — licensed / offline features.
|
||||
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
|
||||
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
|
||||
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
|
||||
|
||||
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
|
||||
|
||||
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
||||
|
||||
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
@@ -426,7 +451,7 @@ The frontend is organized with a clear separation of concerns:
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
|
||||
- Translation files are located in `frontend/editor/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
+15
-4
@@ -52,6 +52,17 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
- Rust and Cargo (required for Tauri desktop app development)
|
||||
- Tauri CLI (install with `cargo install tauri-cli`)
|
||||
|
||||
### Optional System Dependencies
|
||||
|
||||
These are not required to run the app but enable specific features. The app detects them at startup and disables the relevant features if they are missing.
|
||||
|
||||
| Dependency | Feature | Install |
|
||||
|---|---|---|
|
||||
| LibreOffice | File-to-PDF conversions | `brew install libreoffice` / `apt install libreoffice` |
|
||||
| Tesseract | OCR | `brew install tesseract` / `apt install tesseract-ocr` |
|
||||
| WeasyPrint | AI document creation | `brew install weasyprint` / `apt install weasyprint` |
|
||||
| qpdf | PDF optimisation | `brew install qpdf` / `apt install qpdf` |
|
||||
|
||||
### Setup Steps
|
||||
|
||||
1. Clone the repository:
|
||||
@@ -81,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
|
||||
5. **Frontend Setup (Required for Stirling 2.0)**
|
||||
6. **Frontend Setup (Required for Stirling 2.0)**
|
||||
Navigate to the frontend directory and install dependencies using npm.
|
||||
|
||||
### Verify Setup
|
||||
@@ -264,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
|
||||
```
|
||||
|
||||
2. Build the project:
|
||||
@@ -294,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
|
||||
|
||||
## 7. Testing
|
||||
|
||||
@@ -576,7 +587,7 @@ When adding a new feature or modifying existing ones in Stirling-PDF, you'll nee
|
||||
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
|
||||
|
||||
- `messages.properties` (default, usually English)
|
||||
- `messages_en_GB.properties`
|
||||
- `messages_en_US.properties`
|
||||
- `messages_fr_FR.properties`
|
||||
- `messages_de_DE.properties`
|
||||
- etc.
|
||||
|
||||
@@ -16,10 +16,12 @@ if that directory exists, is licensed under the license defined in "frontend/edi
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/portal/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -53,14 +53,14 @@ For full installation options (including desktop and Kubernetes), see our [Docum
|
||||
|
||||
## Support
|
||||
|
||||
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
|
||||
|
||||
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
|
||||
|
||||
|
||||
+56
-8
@@ -25,8 +25,28 @@ includes:
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
pre-commit:
|
||||
taskfile: .taskfiles/pre-commit.yml
|
||||
dir: .
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
# Help (shown when you run `task` with no arguments)
|
||||
# ============================================================
|
||||
|
||||
default:
|
||||
desc: "List the most common commands"
|
||||
silent: true
|
||||
cmds:
|
||||
- |
|
||||
echo "Common commands (run 'task --list' to see all):"
|
||||
echo ""
|
||||
echo " task dev Start backend & frontend on free ports"
|
||||
echo " task backend:dev Start backend on default port"
|
||||
echo " task frontend:dev Start frontend on default port"
|
||||
echo " task desktop:dev Start desktop app"
|
||||
echo " task check Quality gate (lint, typecheck, test, etc.)"
|
||||
|
||||
# ============================================================
|
||||
# Setup & Prerequisites
|
||||
# ============================================================
|
||||
@@ -58,26 +78,41 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
dev:portal:
|
||||
desc: "Start backend + editor; the portal is an admin route at /portal"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:saas
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
vars: { FRONTEND: saas, BACKEND: saas }
|
||||
|
||||
dev:all:
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
|
||||
dev:_all:
|
||||
internal: true
|
||||
vars:
|
||||
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
|
||||
BACKEND: '{{.BACKEND | default "proprietary"}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
@@ -87,11 +122,12 @@ tasks:
|
||||
- task: engine:dev
|
||||
vars:
|
||||
PORT: '{{.ENGINE_PORT}}'
|
||||
- task: backend:dev
|
||||
- task: 'backend:dev:{{.BACKEND}}'
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
- task: frontend:dev
|
||||
AIENGINE_ENABLED: "true"
|
||||
- task: 'frontend:dev:{{.FRONTEND}}'
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
@@ -149,6 +185,16 @@ tasks:
|
||||
- task: frontend:format:check
|
||||
- task: engine:format:check
|
||||
|
||||
# ============================================================
|
||||
# Code generation
|
||||
# ============================================================
|
||||
|
||||
tool-models:
|
||||
desc: "Generate all API models from the Java OpenAPI spec"
|
||||
cmds:
|
||||
- task: frontend:tool-models
|
||||
- task: engine:tool-models
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
# ============================================================
|
||||
@@ -175,4 +221,6 @@ tasks:
|
||||
desc: "Clean all build artifacts"
|
||||
cmds:
|
||||
- task: backend:clean
|
||||
- task: frontend:clean
|
||||
- task: engine:clean
|
||||
- task: pre-commit:clean
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
|
||||
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
|
||||
|
||||
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
|
||||
# Flip to true in app/.env.proprietary.local to test linking locally.
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
|
||||
# SaaS base URL the linked instance calls (register + entitlement).
|
||||
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
|
||||
@@ -1,3 +1,4 @@
|
||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||
# stays ignored via the root .gitignore.
|
||||
!.env.saas
|
||||
!.env.proprietary
|
||||
|
||||
@@ -80,10 +80,18 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License, version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The Apache License, Version 2.0"
|
||||
@@ -108,6 +116,10 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
@@ -172,6 +184,14 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "EPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "LGPL-2.1-only"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Ubuntu Font Licence 1.0"
|
||||
|
||||
@@ -29,13 +29,13 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
api "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
@@ -60,7 +60,7 @@ dependencies {
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
|
||||
api 'com.stirling:jpdfium:1.0.2'
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
|
||||
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
@@ -75,12 +75,12 @@ dependencies {
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
|
||||
}
|
||||
|
||||
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
|
||||
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
|
||||
|
||||
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package stirling.software.common.configuration;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
@@ -122,18 +121,18 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "RunningInDocker")
|
||||
public boolean runningInDocker() {
|
||||
return Files.exists(Paths.get("/.dockerenv"));
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
@Bean(name = "configDirMounted")
|
||||
public boolean isRunningInDockerWithConfig() {
|
||||
Path dockerEnv = Paths.get("/.dockerenv");
|
||||
Path dockerEnv = Path.of("/.dockerenv");
|
||||
// default to true if not docker
|
||||
if (!Files.exists(dockerEnv)) {
|
||||
return true;
|
||||
}
|
||||
Path mountInfo = Paths.get("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown usecase
|
||||
Path mountInfo = Path.of("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown use case
|
||||
if (!Files.exists(mountInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+3
-4
@@ -7,7 +7,6 @@ import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
|
||||
@@ -27,7 +26,7 @@ public class ConfigInitializer {
|
||||
|
||||
public void ensureConfigExists() throws IOException, URISyntaxException {
|
||||
// 1) If settings file doesn't exist, create from template
|
||||
Path destPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
Path destPath = Path.of(InstallationPathConfig.getSettingsPath());
|
||||
|
||||
boolean settingsFileExists = Files.exists(destPath);
|
||||
|
||||
@@ -39,7 +38,7 @@ public class ConfigInitializer {
|
||||
if (settingsFileExists) {
|
||||
// move settings.yml to settings.yml.{timestamp}.bak
|
||||
Path backupPath =
|
||||
Paths.get(
|
||||
Path.of(
|
||||
InstallationPathConfig.getSettingsPath()
|
||||
+ "."
|
||||
+ System.currentTimeMillis()
|
||||
@@ -96,7 +95,7 @@ public class ConfigInitializer {
|
||||
}
|
||||
|
||||
// 3) Ensure custom settings file exists
|
||||
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
|
||||
Path customSettingsPath = Path.of(InstallationPathConfig.getCustomSettingsPath());
|
||||
if (Files.notExists(customSettingsPath)) {
|
||||
Files.createFile(customSettingsPath);
|
||||
log.info("Created custom_settings file: {}", customSettingsPath);
|
||||
|
||||
+5
-6
@@ -3,7 +3,6 @@ package stirling.software.common.configuration;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -201,7 +200,7 @@ public class RuntimePathConfig {
|
||||
|
||||
try {
|
||||
// Normalize to absolute path
|
||||
Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize();
|
||||
Path path = Path.of(pathStr.trim()).toAbsolutePath().normalize();
|
||||
String normalizedPath = path.toString();
|
||||
|
||||
// Check for duplicates
|
||||
@@ -224,9 +223,9 @@ public class RuntimePathConfig {
|
||||
|
||||
private void detectOverlappingPaths(List<String> paths) {
|
||||
for (int i = 0; i < paths.size(); i++) {
|
||||
Path path1 = Paths.get(paths.get(i));
|
||||
Path path1 = Path.of(paths.get(i));
|
||||
for (int j = i + 1; j < paths.size(); j++) {
|
||||
Path path2 = Paths.get(paths.get(j));
|
||||
Path path2 = Path.of(paths.get(j));
|
||||
|
||||
// Check if one path is a parent of the other
|
||||
if (path1.startsWith(path2)) {
|
||||
@@ -246,10 +245,10 @@ public class RuntimePathConfig {
|
||||
|
||||
private void validatePipelinePaths() {
|
||||
try {
|
||||
Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
|
||||
Path finishedPath = Path.of(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
|
||||
|
||||
for (String watchedPathStr : pipelineWatchedFoldersPaths) {
|
||||
Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize();
|
||||
Path watchedPath = Path.of(watchedPathStr).toAbsolutePath().normalize();
|
||||
|
||||
// Check if watched folder is same as finished folder
|
||||
if (watchedPath.equals(finishedPath)) {
|
||||
|
||||
+52
-16
@@ -206,6 +206,11 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Policies {
|
||||
/**
|
||||
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Absolute directories that policy folder input sources and output sinks may read from or
|
||||
* write to. Empty (the default) disables folder access entirely, so a policy can never be
|
||||
@@ -367,6 +372,15 @@ public class ApplicationProperties {
|
||||
*/
|
||||
private String resourceId = "";
|
||||
|
||||
/**
|
||||
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
|
||||
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
|
||||
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
|
||||
* aud=authenticated} - so operators list the audience their IdP actually emits here
|
||||
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
|
||||
*/
|
||||
private List<String> acceptedAudiences = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
|
||||
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
|
||||
@@ -505,6 +519,14 @@ public class ApplicationProperties {
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
private LoginAgreement loginAgreement = new LoginAgreement();
|
||||
|
||||
@Data
|
||||
public static class LoginAgreement {
|
||||
private boolean enabled = false;
|
||||
private boolean showInAnonymousMode = true;
|
||||
private String fallbackText = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -573,7 +595,7 @@ public class ApplicationProperties {
|
||||
public static class SAML2 {
|
||||
private String provider;
|
||||
private Boolean enabled = false;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean autoCreateUser = true;
|
||||
private Boolean blockRegistration = false;
|
||||
private String registrationId = "stirling";
|
||||
|
||||
@@ -650,7 +672,7 @@ public class ApplicationProperties {
|
||||
private String issuer;
|
||||
private String clientId;
|
||||
@ToString.Exclude private String clientSecret;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean autoCreateUser = true;
|
||||
private Boolean blockRegistration = false;
|
||||
private String useAsUsername;
|
||||
private Collection<String> scopes = new ArrayList<>();
|
||||
@@ -721,7 +743,6 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
|
||||
/**
|
||||
@@ -825,8 +846,8 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Trust {
|
||||
private boolean serverAsAnchor = true;
|
||||
private boolean useSystemTrust = false;
|
||||
private boolean useMozillaBundle = false;
|
||||
private boolean useSystemTrust = true;
|
||||
private boolean useMozillaBundle = true;
|
||||
private boolean useAATL = false;
|
||||
private boolean useEUTL = false;
|
||||
}
|
||||
@@ -860,8 +881,8 @@ public class ApplicationProperties {
|
||||
public static class System {
|
||||
private String defaultLocale;
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean showUpdate = true;
|
||||
private boolean showUpdateOnlyAdmin = true;
|
||||
private boolean showSettingsWhenNoLogin = true;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
@@ -869,10 +890,10 @@ public class ApplicationProperties {
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Boolean enableDesktopInstallSlide = true;
|
||||
private Datasource datasource;
|
||||
private boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
private int maxDPI = 500;
|
||||
private boolean enableUrlToPDF;
|
||||
private Html html = new Html();
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
@@ -886,8 +907,9 @@ public class ApplicationProperties {
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
private ServerCertificate serverCertificate = new ServerCertificate();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
@@ -897,6 +919,16 @@ public class ApplicationProperties {
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ServerCertificate {
|
||||
private boolean enabled =
|
||||
true; // Enable server-side "Sign with Stirling-PDF" certificate
|
||||
private String organizationName = "Stirling PDF Inc";
|
||||
private int validity = 365; // Certificate validity in days
|
||||
private boolean regenerateOnStartup =
|
||||
false; // Generate a new certificate on each startup
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.enableAnalytics != null && this.enableAnalytics;
|
||||
}
|
||||
@@ -981,7 +1013,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Sharing {
|
||||
private boolean enabled = false;
|
||||
private boolean linkEnabled = false;
|
||||
private boolean linkEnabled = true;
|
||||
private boolean emailEnabled = false;
|
||||
private int linkExpirationDays = 3;
|
||||
}
|
||||
@@ -996,6 +1028,10 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Signing {
|
||||
private boolean enabled = false;
|
||||
|
||||
// Signing user-picker scope: 'org' (default) = whole instance, anything else =
|
||||
// caller's team only (fail-closed). The saas profile pins 'team'.
|
||||
private String userListScope = "org";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,7 +1187,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private boolean enabled;
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -1203,7 +1239,7 @@ public class ApplicationProperties {
|
||||
private boolean enableInvites = false;
|
||||
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
|
||||
private String host;
|
||||
private int port;
|
||||
private int port = 587;
|
||||
private String username;
|
||||
@ToString.Exclude private String password;
|
||||
private String from;
|
||||
@@ -1230,10 +1266,10 @@ public class ApplicationProperties {
|
||||
@ToString.Exclude private String botToken;
|
||||
private String botUsername;
|
||||
private String pipelineInboxFolder = "telegram";
|
||||
private Boolean customFolderSuffix = false;
|
||||
private Boolean enableAllowUserIDs = false;
|
||||
private Boolean customFolderSuffix = true;
|
||||
private Boolean enableAllowUserIDs = true;
|
||||
private List<Long> allowUserIDs = new ArrayList<>();
|
||||
private Boolean enableAllowChannelIDs = false;
|
||||
private Boolean enableAllowChannelIDs = true;
|
||||
private List<Long> allowChannelIDs = new ArrayList<>();
|
||||
private long processingTimeoutSeconds = 180;
|
||||
private long pollingIntervalMillis = 2000;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
@@ -24,7 +23,7 @@ public class FileInfo {
|
||||
|
||||
// Converts the file path string to a Path object.
|
||||
public Path getFilePathAsPath() {
|
||||
return Paths.get(filePath);
|
||||
return Path.of(filePath);
|
||||
}
|
||||
|
||||
// Formats the file size into a human-readable string.
|
||||
|
||||
@@ -50,6 +50,16 @@ public class InternalApiClient {
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
/**
|
||||
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
|
||||
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
|
||||
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
|
||||
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
|
||||
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
|
||||
* per-tool {@code @RequiresFeature} annotation.
|
||||
*/
|
||||
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
@@ -96,7 +106,23 @@ public class InternalApiClient {
|
||||
if (apiKey != null && !apiKey.isEmpty()) {
|
||||
headers.add("X-API-KEY", apiKey);
|
||||
}
|
||||
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
|
||||
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
|
||||
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
|
||||
// every caller of this dispatcher is an automation surface by design.
|
||||
headers.add(AUTOMATION_HEADER, "true");
|
||||
|
||||
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
|
||||
// without this RestTemplate would use urlencoded instead of the multipart the controller
|
||||
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
|
||||
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
|
||||
boolean hasFilePart =
|
||||
body.values().stream()
|
||||
.flatMap(java.util.List::stream)
|
||||
.anyMatch(v -> v instanceof Resource);
|
||||
if (isAiTool && !hasFilePart) {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
}
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
|
||||
// enable/visibility come from the legal.loginAgreement settings.
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginAgreementService {
|
||||
|
||||
// Locale codes only: rejects path separators and dots so the value can never escape the
|
||||
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
|
||||
private static final Pattern LOCALE_PATTERN =
|
||||
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
|
||||
|
||||
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
|
||||
// from recursing far enough to overflow the stack on a hostile over-length input.
|
||||
private static final int MAX_LOCALE_LENGTH = 35;
|
||||
|
||||
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
|
||||
// wholesale into heap on every public request.
|
||||
private static final long MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public LoginAgreementService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return config().isEnabled();
|
||||
}
|
||||
|
||||
public boolean isShowInAnonymousMode() {
|
||||
return config().isShowInAnonymousMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the markdown to show for the requested language, falling back through the base
|
||||
* language, the configured default locale (and its base), then the configured fallbackText.
|
||||
* Returns an empty string when nothing is configured.
|
||||
*/
|
||||
public String resolveContent(String requestedLang) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
addLocaleCandidates(candidates, requestedLang);
|
||||
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
for (String candidate : candidates) {
|
||||
String content = readFileIfExists(candidate);
|
||||
if (content != null && !content.isBlank()) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
String fallback = config().getFallbackText();
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
|
||||
*/
|
||||
public String readRawForLocale(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
String content = readFileIfExists(locale);
|
||||
return content == null ? "" : content;
|
||||
}
|
||||
|
||||
/** Admin write. Blank content deletes the file so it falls back cleanly. */
|
||||
public void writeForLocale(String locale, String content) throws IOException {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("Invalid locale: " + locale);
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
Files.deleteIfExists(file);
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(file.getParent());
|
||||
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
|
||||
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
|
||||
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
|
||||
try {
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
file,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locales that currently have a markdown file, for the admin editor. */
|
||||
public Set<String> listLocalesWithContent() {
|
||||
Set<String> result = new TreeSet<>();
|
||||
Path dir = disclaimerDir();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return result;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".md"))
|
||||
.map(name -> name.substring(0, name.length() - ".md".length()))
|
||||
.filter(this::isValidLocale)
|
||||
.forEach(result::add);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed listing login agreement files", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ApplicationProperties.Legal.LoginAgreement config() {
|
||||
return applicationProperties.getLegal().getLoginAgreement();
|
||||
}
|
||||
|
||||
private Path disclaimerDir() {
|
||||
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
|
||||
}
|
||||
|
||||
private void addLocaleCandidates(List<String> out, String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
if (!out.contains(locale)) {
|
||||
out.add(locale);
|
||||
}
|
||||
String base = locale.split("[_-]", 2)[0];
|
||||
if (!base.equals(locale) && !out.contains(base)) {
|
||||
out.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
private String readFileIfExists(String locale) {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
|
||||
// planted symlink can't expose files outside the disclaimer dir via the public read.
|
||||
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.size(file) > MAX_FILE_BYTES) {
|
||||
log.warn(
|
||||
"Login agreement file for locale {} exceeds {} bytes; ignoring",
|
||||
locale,
|
||||
MAX_FILE_BYTES);
|
||||
return null;
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed reading login agreement file for locale {}", locale, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path resolveLocaleFile(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
Path dir = disclaimerDir();
|
||||
Path file = dir.resolve(locale + ".md").normalize();
|
||||
// Defence in depth: the regex already blocks separators, but confirm containment.
|
||||
if (!file.startsWith(dir)) {
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private boolean isValidLocale(String locale) {
|
||||
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
|
||||
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
|
||||
return locale != null
|
||||
&& locale.length() <= MAX_LOCALE_LENGTH
|
||||
&& LOCALE_PATTERN.matcher(locale).matches();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package stirling.software.common.service;
|
||||
import java.io.IOException;
|
||||
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;
|
||||
@@ -35,7 +34,7 @@ public class MobileScannerService {
|
||||
public MobileScannerService() throws IOException {
|
||||
// Create temp directory for mobile scanner uploads
|
||||
this.tempDirectory =
|
||||
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Path.of(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Files.createDirectories(tempDirectory);
|
||||
log.info("Mobile scanner temp directory: {}", tempDirectory);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.lang.management.OperatingSystemMXBean;
|
||||
import java.lang.management.RuntimeMXBean;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -160,7 +160,7 @@ public class PostHogService {
|
||||
}
|
||||
|
||||
private boolean isRunningInDocker() {
|
||||
return Files.exists(Paths.get("/.dockerenv"));
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
private Map<String, Object> getDockerMetrics() {
|
||||
|
||||
@@ -255,7 +255,7 @@ public class GeneralUtils {
|
||||
String pattern = locationPattern;
|
||||
if (pattern.startsWith("file:")) {
|
||||
String rawPath = pattern.substring(5).replace("\\*", "").replace("/*", "");
|
||||
Path normalizePath = Paths.get(rawPath).normalize();
|
||||
Path normalizePath = Path.of(rawPath).normalize();
|
||||
pattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
|
||||
}
|
||||
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
|
||||
@@ -837,7 +837,7 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public boolean createDir(String path) {
|
||||
Path folder = Paths.get(path);
|
||||
Path folder = Path.of(path);
|
||||
if (!Files.exists(folder)) {
|
||||
try {
|
||||
Files.createDirectories(folder);
|
||||
@@ -867,7 +867,7 @@ public class GeneralUtils {
|
||||
|
||||
public void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||
String[] keyArray = key.split("\\.");
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
Path settingsPath = Path.of(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
@@ -888,7 +888,7 @@ public class GeneralUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
Path settingsPath = Path.of(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
|
||||
// Apply all updates to the same YamlHelper instance
|
||||
@@ -974,11 +974,11 @@ public class GeneralUtils {
|
||||
*/
|
||||
public void extractPipeline() throws IOException {
|
||||
Path pipelineDir =
|
||||
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||
Path.of(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||
Files.createDirectories(pipelineDir);
|
||||
|
||||
for (String name : DEFAULT_VALID_PIPELINE) {
|
||||
if (!Paths.get(name).getFileName().toString().equals(name)) {
|
||||
if (!Path.of(name).getFileName().toString().equals(name)) {
|
||||
log.error("Invalid pipeline file name: {}", name);
|
||||
throw new IllegalArgumentException("Invalid pipeline file name: " + name);
|
||||
}
|
||||
@@ -1014,7 +1014,7 @@ public class GeneralUtils {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must not contain path traversal characters");
|
||||
}
|
||||
if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) {
|
||||
if (!Path.of(scriptName).getFileName().toString().equals(scriptName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must not contain path traversal characters");
|
||||
}
|
||||
@@ -1024,7 +1024,7 @@ public class GeneralUtils {
|
||||
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
|
||||
}
|
||||
|
||||
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||
Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||
Files.createDirectories(scriptsDir);
|
||||
|
||||
Path target = scriptsDir.resolve(scriptName);
|
||||
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public String getLocalNetworkIp() {
|
||||
String routed = detectLocalIpViaDefaultRoute();
|
||||
if (routed != null) {
|
||||
return routed;
|
||||
}
|
||||
try {
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) return null;
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
return addr.getHostAddress();
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectBestSiteLocalIp(collectInterfaceInfo());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to detect local network IP", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String detectLocalIpViaDefaultRoute() {
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.connect(InetAddress.getByName("8.8.8.8"), 53);
|
||||
InetAddress local = socket.getLocalAddress();
|
||||
if (local instanceof Inet4Address
|
||||
&& !local.isAnyLocalAddress()
|
||||
&& !local.isLoopbackAddress()
|
||||
&& !local.isLinkLocalAddress()) {
|
||||
return local.getHostAddress();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Default-route IP detection failed; will scan interfaces", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
|
||||
List<NetworkInterfaceInfo> infos = new ArrayList<>();
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) {
|
||||
return infos;
|
||||
}
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
|
||||
List<String> siteLocalIpv4s = new ArrayList<>();
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
siteLocalIpv4s.add(addr.getHostAddress());
|
||||
}
|
||||
}
|
||||
if (siteLocalIpv4s.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] mac = iface.getHardwareAddress();
|
||||
infos.add(
|
||||
new NetworkInterfaceInfo(
|
||||
iface.getName(),
|
||||
iface.getDisplayName(),
|
||||
iface.getIndex(),
|
||||
iface.isUp(),
|
||||
iface.isLoopback(),
|
||||
iface.isPointToPoint(),
|
||||
iface.isVirtual(),
|
||||
mac != null && mac.length > 0,
|
||||
siteLocalIpv4s));
|
||||
} catch (SocketException e) {
|
||||
log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e);
|
||||
}
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
static String selectBestSiteLocalIp(List<NetworkInterfaceInfo> interfaces) {
|
||||
return interfaces.stream()
|
||||
.filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual())
|
||||
.filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName()))
|
||||
.flatMap(
|
||||
i ->
|
||||
i.siteLocalIpv4s().stream()
|
||||
.map(
|
||||
ip ->
|
||||
new ScoredAddress(
|
||||
ip,
|
||||
scoreInterface(i, ip),
|
||||
i.index())))
|
||||
.max(
|
||||
Comparator.comparingInt(ScoredAddress::score)
|
||||
.thenComparing(
|
||||
Comparator.comparingInt(ScoredAddress::interfaceIndex)
|
||||
.reversed()))
|
||||
.map(ScoredAddress::ip)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static int scoreInterface(NetworkInterfaceInfo iface, String ip) {
|
||||
int score = 0;
|
||||
if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) {
|
||||
score += 100;
|
||||
}
|
||||
if (iface.hasHardwareAddress()) {
|
||||
score += 20;
|
||||
}
|
||||
if (ip.startsWith("192.168.")) {
|
||||
score += 30;
|
||||
} else if (ip.startsWith("10.")) {
|
||||
score += 20;
|
||||
} else {
|
||||
score += 5;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
static boolean isLikelyVirtualInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
String[] namePrefixes = {
|
||||
"tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl",
|
||||
"llw"
|
||||
};
|
||||
for (String prefix : namePrefixes) {
|
||||
if (n.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] displayMarkers = {
|
||||
"vmware",
|
||||
"virtualbox",
|
||||
"virtual box",
|
||||
"vbox",
|
||||
"hyper-v",
|
||||
"hyperv",
|
||||
"vethernet",
|
||||
"windows subsystem for linux",
|
||||
"wsl",
|
||||
"docker",
|
||||
"tap-windows",
|
||||
"tunnel",
|
||||
"vpn",
|
||||
"zerotier",
|
||||
"tailscale",
|
||||
"bluetooth",
|
||||
"teredo",
|
||||
"isatap",
|
||||
"loopback",
|
||||
"pseudo",
|
||||
"virtual"
|
||||
};
|
||||
for (String marker : displayMarkers) {
|
||||
if (d.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isLikelyPhysicalInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
return n.startsWith("eth")
|
||||
|| n.startsWith("en")
|
||||
|| n.startsWith("wl")
|
||||
|| n.startsWith("em")
|
||||
|| d.contains("ethernet")
|
||||
|| d.contains("wi-fi")
|
||||
|| d.contains("wifi")
|
||||
|| d.contains("wireless");
|
||||
}
|
||||
|
||||
record NetworkInterfaceInfo(
|
||||
String name,
|
||||
String displayName,
|
||||
int index,
|
||||
boolean up,
|
||||
boolean loopback,
|
||||
boolean pointToPoint,
|
||||
boolean virtual,
|
||||
boolean hasHardwareAddress,
|
||||
List<String> siteLocalIpv4s) {}
|
||||
|
||||
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import java.io.File;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -20,7 +19,7 @@ public class JarPathUtil {
|
||||
public static Path currentJar() {
|
||||
try {
|
||||
Path jar =
|
||||
Paths.get(
|
||||
Path.of(
|
||||
JarPathUtil.class
|
||||
.getProtectionDomain()
|
||||
.getCodeSource()
|
||||
@@ -61,14 +60,14 @@ public class JarPathUtil {
|
||||
}
|
||||
|
||||
// Location 2: ./build/libs/ (development build)
|
||||
possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
possibleLocations[1] = Path.of("build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Location 3: app/common/build/libs/ (multi-module build)
|
||||
possibleLocations[2] =
|
||||
Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
Path.of("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Location 4: Current working directory
|
||||
possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath();
|
||||
possibleLocations[3] = Path.of("restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Check each location
|
||||
for (Path location : possibleLocations) {
|
||||
|
||||
@@ -244,10 +244,7 @@ public class SvgSanitizer {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized.startsWith("http://")
|
||||
|| normalized.startsWith("https://")
|
||||
|| normalized.startsWith("//")
|
||||
|| normalized.startsWith("file:");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isUrlAllowed(String url) {
|
||||
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
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.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
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 stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
|
||||
* {@code init()} during construction and then applies environment overrides. We build it with a
|
||||
* real {@link ApplicationProperties} (whose System/Endpoints sub-objects are non-null by default)
|
||||
* so the constructor runs cleanly without any mocking.
|
||||
*/
|
||||
class EndpointConfigurationGapTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
|
||||
*/
|
||||
private EndpointConfiguration build(boolean runningProOrHigher) {
|
||||
return new EndpointConfiguration(applicationProperties, runningProOrHigher);
|
||||
}
|
||||
|
||||
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
|
||||
private EndpointConfiguration buildDefault() {
|
||||
return build(false);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("endpointKeyForUri (static)")
|
||||
class EndpointKeyForUriTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for null uri")
|
||||
void nullUri() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when uri does not contain /api/v1")
|
||||
void notApiPath() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/foo/bar/baz"));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("https://example.com/home"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when uri has too few path segments")
|
||||
void tooFewSegments() {
|
||||
// "/api/v1/general" splits to ["", "api", "v1", "general"] -> length 4, not > 4
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts plain endpoint key from a standard /api/v1/<group>/<endpoint> uri")
|
||||
void plainEndpoint() {
|
||||
assertEquals(
|
||||
"remove-pages",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("builds a <from>-to-<to> key for convert endpoints")
|
||||
void convertEndpoint() {
|
||||
assertEquals(
|
||||
"pdf-to-img",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convert path without a target segment falls back to the segment after group")
|
||||
void convertWithoutTarget() {
|
||||
// "/api/v1/convert/pdf" -> length 5, the convert branch needs length > 5
|
||||
assertEquals("pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("enable / disable endpoint")
|
||||
class EnableDisableEndpointTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a freshly registered endpoint is enabled by default")
|
||||
void enabledByDefault() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isEndpointEnabled("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disableEndpoint marks the endpoint disabled")
|
||||
void disableEndpoint() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enableEndpoint re-enables a previously disabled endpoint")
|
||||
void reEnableEndpoint() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
config.enableEndpoint("merge-pdfs");
|
||||
assertTrue(config.isEndpointEnabled("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leading slash is normalized away on disable")
|
||||
void leadingSlashNormalizedOnDisable() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("/merge-pdfs");
|
||||
// both forms resolve to the same key
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
assertFalse(config.isEndpointEnabled("/merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isEndpointEnabled tolerates a leading slash on the query")
|
||||
void leadingSlashOnQuery() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isEndpointEnabled("/merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling clears with enable, removing the disable reason")
|
||||
void enableClearsReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("split-pages", DisableReason.DEPENDENCY);
|
||||
assertEquals(
|
||||
DisableReason.DEPENDENCY,
|
||||
config.getEndpointAvailability("split-pages").getReason());
|
||||
config.enableEndpoint("split-pages");
|
||||
EndpointAvailability availability = config.getEndpointAvailability("split-pages");
|
||||
assertTrue(availability.isEnabled());
|
||||
assertNull(availability.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isEndpointEnabledForUri")
|
||||
class IsEndpointEnabledForUriTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("translates a /api/v1 uri to a key and reports its status")
|
||||
void translatesUri() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertFalse(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to treating a non-api uri as a raw key")
|
||||
void fallsBackToRawKey() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
// non-api path: key resolution returns null, so the uri itself is used as the key
|
||||
assertFalse(config.isEndpointEnabledForUri("merge-pdfs"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("group enable / disable")
|
||||
class GroupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a functional group with all endpoints enabled reports enabled")
|
||||
void functionalGroupEnabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isGroupEnabled("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling a functional group cascades to all its endpoints")
|
||||
void disableFunctionalGroupCascades() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
assertFalse(config.isGroupEnabled("PageOps"));
|
||||
assertFalse(config.isEndpointEnabled("remove-pages"));
|
||||
assertFalse(config.isEndpointEnabled("split-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("re-enabling a functional group re-enables its endpoints")
|
||||
void enableFunctionalGroupRestores() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
assertFalse(config.isEndpointEnabled("remove-pages"));
|
||||
config.enableGroup("PageOps");
|
||||
assertTrue(config.isEndpointEnabled("remove-pages"));
|
||||
assertTrue(config.isGroupEnabled("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a functional group with one disabled endpoint is not enabled")
|
||||
void functionalGroupWithDisabledEndpoint() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("remove-pages");
|
||||
assertFalse(config.isGroupEnabled("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabledGroups reflects disabled groups and getDisabledGroups returns a copy")
|
||||
void getDisabledGroupsReturnsCopy() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
Set<String> disabled = config.getDisabledGroups();
|
||||
assertTrue(disabled.contains("PageOps"));
|
||||
// mutating the returned set must not affect internal state
|
||||
disabled.clear();
|
||||
assertTrue(config.getDisabledGroups().contains("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown group with no endpoints is not enabled")
|
||||
void unknownGroupNotEnabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertFalse(config.isGroupEnabled("NoSuchGroupXyz"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("tool group semantics")
|
||||
class ToolGroupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a tool group is enabled until explicitly disabled")
|
||||
void toolGroupEnabledUntilDisabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isGroupEnabled("qpdf"));
|
||||
config.disableGroup("qpdf");
|
||||
assertFalse(config.isGroupEnabled("qpdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling a tool group does NOT cascade to its endpoints directly")
|
||||
void toolGroupNoCascade() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
// repair has alternatives (qpdf, Ghostscript); disabling only qpdf keeps it enabled
|
||||
config.disableGroup("qpdf");
|
||||
assertTrue(config.isEndpointEnabled("repair"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoint with alternatives is disabled only when all tool groups are gone")
|
||||
void allAlternativesDisabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("qpdf");
|
||||
config.disableGroup("Ghostscript");
|
||||
// repair's only alternatives are qpdf and Ghostscript
|
||||
assertFalse(config.isEndpointEnabled("repair"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoint with a still-enabled alternative stays enabled")
|
||||
void oneAlternativeRemains() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
// compress-pdf alternatives: qpdf, Ghostscript, Java
|
||||
config.disableGroup("qpdf");
|
||||
config.disableGroup("Ghostscript");
|
||||
assertTrue(config.isEndpointEnabled("compress-pdf"));
|
||||
config.disableGroup("Java");
|
||||
assertFalse(config.isEndpointEnabled("compress-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-dependency endpoint (no alternatives) disabled when its tool group is")
|
||||
void singleDependencyDisabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
// pdf-to-epub depends on Calibre, no alternatives registered
|
||||
assertTrue(config.isEndpointEnabled("pdf-to-epub"));
|
||||
config.disableGroup("Calibre");
|
||||
assertFalse(config.isEndpointEnabled("pdf-to-epub"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addEndpointToGroup / addEndpointAlternative")
|
||||
class RegistrationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("addEndpointToGroup makes the endpoint part of the group")
|
||||
void addEndpointToGroup() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
|
||||
Set<String> endpoints = config.getEndpointsForGroup("CustomGroup");
|
||||
assertTrue(endpoints.contains("custom-endpoint"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling a custom functional group disables its added endpoint")
|
||||
void customFunctionalGroupCascades() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
|
||||
assertTrue(config.isEndpointEnabled("custom-endpoint"));
|
||||
config.disableGroup("CustomGroup");
|
||||
assertFalse(config.isEndpointEnabled("custom-endpoint"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getEndpointsForGroup returns an empty set for unknown groups")
|
||||
void unknownGroupEmptySet() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
Set<String> endpoints = config.getEndpointsForGroup("NoSuchGroupXyz");
|
||||
assertNotNull(endpoints);
|
||||
assertTrue(endpoints.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getEndpointAvailability / determineDisableReason")
|
||||
class AvailabilityTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an enabled endpoint has a null disable reason")
|
||||
void enabledHasNullReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
|
||||
assertTrue(availability.isEnabled());
|
||||
assertNull(availability.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit disable preserves the supplied reason")
|
||||
void explicitDisableReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs", DisableReason.DEPENDENCY);
|
||||
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
|
||||
assertFalse(availability.isEnabled());
|
||||
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default disableEndpoint reason is CONFIG")
|
||||
void defaultDisableReasonIsConfig() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertEquals(
|
||||
DisableReason.CONFIG, config.getEndpointAvailability("merge-pdfs").getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoint disabled via functional group reports the group's reason")
|
||||
void functionalGroupReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps", DisableReason.DEPENDENCY);
|
||||
EndpointAvailability availability = config.getEndpointAvailability("crop");
|
||||
assertFalse(availability.isEnabled());
|
||||
// crop is disabled both via group cascade and group membership; reason is DEPENDENCY
|
||||
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAllEndpoints")
|
||||
class GetAllEndpointsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("aggregates endpoints across all groups")
|
||||
void aggregatesAcrossGroups() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
Set<String> all = config.getAllEndpoints();
|
||||
assertTrue(all.contains("merge-pdfs"));
|
||||
assertTrue(all.contains("compress-pdf"));
|
||||
assertTrue(all.contains("ocr-pdf"));
|
||||
assertFalse(all.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom endpoints registered after init appear in getAllEndpoints")
|
||||
void includesCustomEndpoints() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.addEndpointToGroup("CustomGroup", "brand-new-endpoint");
|
||||
assertTrue(config.getAllEndpoints().contains("brand-new-endpoint"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("environment / constructor driven configuration")
|
||||
class EnvironmentConfigTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("url-to-pdf is disabled when enableUrlToPDF is false (default)")
|
||||
void urlToPdfDisabledByDefault() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertFalse(config.isEndpointEnabled("url-to-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("url-to-pdf stays enabled when enableUrlToPDF is true")
|
||||
void urlToPdfEnabledWhenFlagSet() {
|
||||
applicationProperties.getSystem().setEnableUrlToPDF(true);
|
||||
EndpointConfiguration config = build(false);
|
||||
assertTrue(config.isEndpointEnabled("url-to-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoints.toRemove disables the listed endpoints at construction")
|
||||
void endpointsToRemove() {
|
||||
applicationProperties
|
||||
.getEndpoints()
|
||||
.setToRemove(List.of(" merge-pdfs ", "split-pages"));
|
||||
EndpointConfiguration config = build(false);
|
||||
// values are trimmed before disabling
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
assertFalse(config.isEndpointEnabled("split-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoints.groupsToRemove disables the listed groups at construction")
|
||||
void groupsToRemove() {
|
||||
applicationProperties.getEndpoints().setGroupsToRemove(List.of(" PageOps "));
|
||||
EndpointConfiguration config = build(false);
|
||||
assertTrue(config.getDisabledGroups().contains("PageOps"));
|
||||
assertFalse(config.isEndpointEnabled("remove-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-pro build disables the enterprise group")
|
||||
void nonProDisablesEnterprise() {
|
||||
EndpointConfiguration config = build(false);
|
||||
assertTrue(config.getDisabledGroups().contains("enterprise"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pro build does not disable the enterprise group")
|
||||
void proDoesNotDisableEnterprise() {
|
||||
EndpointConfiguration config = build(true);
|
||||
assertFalse(config.getDisabledGroups().contains("enterprise"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getEndpointStatuses (Lombok getter) and logging summary")
|
||||
class MiscTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("getEndpointStatuses reflects explicit disable state")
|
||||
void endpointStatusesReflectDisable() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertEquals(Boolean.FALSE, config.getEndpointStatuses().get("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logDisabledEndpointsSummary runs without throwing")
|
||||
void logSummaryDoesNotThrow() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
config.disableGroup("qpdf");
|
||||
// purely a smoke test of the logging branch coverage
|
||||
config.logDisabledEndpointsSummary();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logDisabledEndpointsSummary runs when nothing is disabled")
|
||||
void logSummaryNothingDisabled() {
|
||||
applicationProperties.getSystem().setEnableUrlToPDF(true);
|
||||
EndpointConfiguration config = build(true);
|
||||
config.logDisabledEndpointsSummary();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
import java.awt.geom.Point2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
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.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.pdf.parser.PageImageLocator.ImageBox;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PageImageLocator}. PDFs are built in memory with PDFBox so each test is
|
||||
* deterministic and needs no fixtures or native libraries. The locator transforms the image unit
|
||||
* square through the CTM, so an image drawn at {@code (x, y)} with size {@code (w, h)} must yield
|
||||
* the box {@code (x, y, x+w, y+h)}.
|
||||
*/
|
||||
class PageImageLocatorTest {
|
||||
|
||||
/** A tiny opaque raster; pixel content is irrelevant, only its placement matters. */
|
||||
private static PDImageXObject tinyImage(PDDocument doc) throws Exception {
|
||||
BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
|
||||
return LosslessFactory.createFromImage(doc, img);
|
||||
}
|
||||
|
||||
/** Builds a one-page PDF that draws one image at the given placement. */
|
||||
private static byte[] pdfWithImageAt(float x, float y, float w, float h) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = tinyImage(doc);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, x, y, w, h);
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("drawImage bounding boxes")
|
||||
class DrawImageBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("a single image yields one box with the page index and CTM-derived bounds")
|
||||
void singleImageBox() throws Exception {
|
||||
byte[] pdf = pdfWithImageAt(100f, 200f, 50f, 80f);
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
|
||||
locator.processPage(doc.getPage(0));
|
||||
|
||||
List<ImageBox> boxes = locator.getImageBoxes();
|
||||
assertThat(boxes).hasSize(1);
|
||||
ImageBox box = boxes.get(0);
|
||||
assertThat(box.pageIndex()).isZero();
|
||||
assertThat(box.x1()).isCloseTo(100f, within(0.5f));
|
||||
assertThat(box.y1()).isCloseTo(200f, within(0.5f));
|
||||
assertThat(box.x2()).isCloseTo(150f, within(0.5f));
|
||||
assertThat(box.y2()).isCloseTo(280f, within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the supplied page index is stored on every box")
|
||||
void pageIndexStored() throws Exception {
|
||||
byte[] pdf = pdfWithImageAt(10f, 10f, 20f, 20f);
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 7);
|
||||
locator.processPage(doc.getPage(0));
|
||||
assertThat(locator.getImageBoxes().get(0).pageIndex()).isEqualTo(7);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two images on one page yield two boxes")
|
||||
void twoImages() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = tinyImage(doc);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 50f, 50f, 30f, 30f);
|
||||
cs.drawImage(image, 200f, 400f, 60f, 40f);
|
||||
}
|
||||
byte[] pdf = save(doc);
|
||||
try (PDDocument reopened = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
|
||||
locator.processPage(reopened.getPage(0));
|
||||
assertThat(locator.getImageBoxes()).hasSize(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a page with no images yields no boxes")
|
||||
void noImages() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
byte[] pdf = save(doc);
|
||||
try (PDDocument reopened = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
|
||||
locator.processPage(reopened.getPage(0));
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getImageBoxes is empty before any page is processed")
|
||||
void emptyBeforeProcessing() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("path operation no-ops")
|
||||
class PathNoOps {
|
||||
|
||||
private PageImageLocator newLocator() {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
return new PageImageLocator(page, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moveTo updates the current point")
|
||||
void moveToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.moveTo(12f, 34f);
|
||||
Point2D current = locator.getCurrentPoint();
|
||||
assertThat(current.getX()).isEqualTo(12d);
|
||||
assertThat(current.getY()).isEqualTo(34d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lineTo updates the current point")
|
||||
void lineToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.lineTo(5f, 6f);
|
||||
assertThat(locator.getCurrentPoint().getX()).isEqualTo(5d);
|
||||
assertThat(locator.getCurrentPoint().getY()).isEqualTo(6d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("curveTo updates the current point to the final control point")
|
||||
void curveToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.curveTo(1f, 1f, 2f, 2f, 9f, 8f);
|
||||
assertThat(locator.getCurrentPoint().getX()).isEqualTo(9d);
|
||||
assertThat(locator.getCurrentPoint().getY()).isEqualTo(8d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rectangle, clip, path and shading operations are no-ops that do not throw")
|
||||
void otherOpsDoNotThrow() {
|
||||
PageImageLocator locator = newLocator();
|
||||
Point2D p = new Point2D.Float(0f, 0f);
|
||||
// None of these record anything or alter state; they must simply not throw.
|
||||
locator.appendRectangle(p, p, p, p);
|
||||
locator.clip(0);
|
||||
locator.closePath();
|
||||
locator.endPath();
|
||||
locator.strokePath();
|
||||
locator.fillPath(0);
|
||||
locator.fillAndStrokePath(0);
|
||||
locator.shadingFill(COSName.getPDFName("Sh0"));
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
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.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.RawPage;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.TableCell;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.TableRow;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
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.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TabulaTableParser}. Tables are built in-memory with PDFBox so the tests are
|
||||
* deterministic and need no fixtures, network, or external processes.
|
||||
*/
|
||||
class TabulaTableParserGapTest {
|
||||
|
||||
private final TabulaTableParser parser = new TabulaTableParser();
|
||||
|
||||
// ── error / empty branches ───────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Empty and error branches")
|
||||
class EmptyAndErrorBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("page number 0 is out of Tabula's 1-based range -> empty list, no throw")
|
||||
void pageNumberZeroReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"hello"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> result = parser.parse(doc, 0);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page number beyond the document -> empty list, exception swallowed")
|
||||
void pageNumberOutOfRangeReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"hello"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> result = parser.parse(doc, 99);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative page number -> empty list")
|
||||
void negativePageNumberReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"hello"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertTrue(parser.parse(doc, -5).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lattice mode on a page with no ruled lines -> no tables")
|
||||
void latticeWithNoRulingsReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"just some prose", "no table here"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> result = parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertNotNull(result);
|
||||
assertTrue(
|
||||
result.isEmpty(), "borderless text must not be detected in lattice mode");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank page in lattice mode -> empty list")
|
||||
void blankPageLatticeReturnsEmpty() throws Exception {
|
||||
byte[] pdf = blankPdf();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertTrue(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── stream mode (BasicExtractionAlgorithm) ───────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Stream mode")
|
||||
class StreamMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with text yields at least one well-formed fragment")
|
||||
void streamOnTextProducesFragment() throws Exception {
|
||||
byte[] pdf =
|
||||
pdfWithText(new String[] {"Name Age City", "Alice 30 Paris", "Bob 25 Rome"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertNotNull(fragments);
|
||||
assertFalse(fragments.isEmpty(), "stream mode always builds a table from text");
|
||||
assertFragmentWellFormed(fragments.get(0), 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fragment ids encode page and index")
|
||||
void streamFragmentIdFormat() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"col1 col2", "a b"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertFalse(fragments.isEmpty());
|
||||
assertEquals("tbl-p1-0", fragments.get(0).tableId());
|
||||
assertEquals(1, fragments.get(0).pageNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rawRows and the parsed rows stay in lockstep")
|
||||
void streamRowsMatchRawRows() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"x y", "1 2", "3 4"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertFalse(fragments.isEmpty());
|
||||
TableFragment f = fragments.get(0);
|
||||
assertEquals(f.rawRows().size(), f.rows().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── lattice mode with a real bordered grid ───────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Lattice mode")
|
||||
class LatticeMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("bordered grid is detected and produces well-formed fragments")
|
||||
void latticeDetectsBorderedTable() throws Exception {
|
||||
byte[] pdf = pdfWithGrid();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertNotNull(fragments);
|
||||
assertFalse(
|
||||
fragments.isEmpty(), "a clean ruled grid must be detected in lattice mode");
|
||||
TableFragment f = fragments.get(0);
|
||||
assertFragmentWellFormed(f, 1, 0);
|
||||
assertTrue(f.columnCount() >= 1, "a detected grid must have at least one column");
|
||||
assertFalse(f.rawRows().isEmpty(), "a detected grid must have rows");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convenience overload with page number routes to lattice mode")
|
||||
void parseByPageNumberDetectsGrid() throws Exception {
|
||||
byte[] pdf = pdfWithGrid();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments = parser.parse(doc, 1);
|
||||
assertNotNull(fragments);
|
||||
assertFalse(fragments.isEmpty());
|
||||
assertEquals(1, fragments.get(0).pageNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cell text is normalised (trimmed, newlines collapsed)")
|
||||
void latticeCellTextIsNormalised() throws Exception {
|
||||
byte[] pdf = pdfWithGrid();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertFalse(fragments.isEmpty());
|
||||
for (List<String> row : fragments.get(0).rawRows()) {
|
||||
for (String cell : row) {
|
||||
assertNotNull(cell);
|
||||
assertFalse(cell.contains("\n"), "newlines must be collapsed");
|
||||
assertFalse(cell.contains("\r"), "carriage returns must be collapsed");
|
||||
assertEquals(cell.trim(), cell, "cell text must be trimmed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── contract invariants ──────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Contract invariants")
|
||||
class ContractInvariants {
|
||||
|
||||
@Test
|
||||
@DisplayName("parse never returns null")
|
||||
void parseNeverReturnsNull() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"abc"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertNotNull(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())));
|
||||
assertNotNull(parser.parse(doc, 1));
|
||||
assertNotNull(parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the document is not closed by the parser")
|
||||
void documentRemainsOpenAfterParse() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"keep me open"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
// ObjectExtractor.close() would close the underlying COSDocument; the parser must
|
||||
// not.
|
||||
assertFalse(
|
||||
doc.getDocument().isClosed(),
|
||||
"parser must not close the caller's document");
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Asserts every field of a fragment satisfies the documented contract. */
|
||||
private static void assertFragmentWellFormed(
|
||||
TableFragment f, int expectedPage, int expectedIndex) {
|
||||
assertNotNull(f);
|
||||
assertEquals(expectedPage, f.pageNumber());
|
||||
assertEquals("tbl-p" + expectedPage + "-" + expectedIndex, f.tableId());
|
||||
assertNotNull(f.bounds());
|
||||
assertNotNull(f.headers());
|
||||
assertTrue(f.headers().isEmpty(), "headers are deferred to v2 and must be empty");
|
||||
assertNotNull(f.rows());
|
||||
assertNotNull(f.rawRows());
|
||||
assertNotNull(f.warnings());
|
||||
assertSame(null, f.continuedFromPage(), "continuedFromPage is deferred to v2");
|
||||
assertTrue(f.columnCount() >= 0);
|
||||
assertTrue(f.confidence() >= 0f && f.confidence() <= 1f, "confidence must be within [0,1]");
|
||||
assertEquals(f.rawRows().size(), f.rows().size());
|
||||
|
||||
for (TableRow row : f.rows()) {
|
||||
assertNotNull(row.cells());
|
||||
for (TableCell cell : row.cells()) {
|
||||
assertNotNull(cell.text());
|
||||
assertNotNull(cell.bounds());
|
||||
assertEquals(1, cell.colSpan(), "colSpan is always 1 in v1");
|
||||
assertEquals(1, cell.rowSpan(), "rowSpan is always 1 in v1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pdfWithText(String[] lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] blankPdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a small 3-row x 3-column ruled grid with text in each cell. The ruled lines make the
|
||||
* table detectable by lattice mode.
|
||||
*/
|
||||
private static byte[] pdfWithGrid() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
|
||||
float left = 100f;
|
||||
float right = 400f;
|
||||
float top = 700f;
|
||||
float bottom = 550f;
|
||||
int cols = 3;
|
||||
int rows = 3;
|
||||
float colStep = (right - left) / cols;
|
||||
float rowStep = (top - bottom) / rows;
|
||||
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setStrokingColor(Color.BLACK);
|
||||
cs.setLineWidth(1f);
|
||||
|
||||
// vertical lines
|
||||
for (int c = 0; c <= cols; c++) {
|
||||
float x = left + c * colStep;
|
||||
cs.moveTo(x, bottom);
|
||||
cs.lineTo(x, top);
|
||||
}
|
||||
// horizontal lines
|
||||
for (int r = 0; r <= rows; r++) {
|
||||
float yLine = bottom + r * rowStep;
|
||||
cs.moveTo(left, yLine);
|
||||
cs.lineTo(right, yLine);
|
||||
}
|
||||
cs.stroke();
|
||||
|
||||
// cell text
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(left + c * colStep + 5f, top - (r + 1) * rowStep + 6f);
|
||||
cs.showText("R" + r + "C" + c);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
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.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class AppConfigTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockEnvironment env;
|
||||
private AppConfig appConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
env = new MockEnvironment();
|
||||
appConfig = new AppConfig(env, applicationProperties);
|
||||
ReflectionTestUtils.setField(appConfig, "contextPath", "/");
|
||||
ReflectionTestUtils.setField(appConfig, "serverPort", "8080");
|
||||
ReflectionTestUtils.setField(appConfig, "v2Enabled", true);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Value-backed getters and simple beans")
|
||||
class SimpleBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("getter fields reflect injected @Value values")
|
||||
void valueGetters() {
|
||||
assertThat(appConfig.getContextPath()).isEqualTo("/");
|
||||
assertThat(appConfig.getServerPort()).isEqualTo("8080");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2Enabled bean mirrors the field")
|
||||
void v2EnabledBean() {
|
||||
assertThat(appConfig.v2Enabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constant beans return fixed values")
|
||||
void constants() {
|
||||
assertThat(appConfig.appName()).isEqualTo("Stirling PDF");
|
||||
assertThat(appConfig.homeText()).isEqualTo("null");
|
||||
assertThat(appConfig.contextPath("/ctx")).isEqualTo("/ctx");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appVersion resolves from version.properties on classpath")
|
||||
void appVersion() {
|
||||
assertThat(appConfig.appVersion()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("StirlingPDFLabel embeds version")
|
||||
void stirlingLabel() {
|
||||
assertThat(appConfig.stirlingPDFLabel()).startsWith("Stirling-PDF v");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Beans backed by ApplicationProperties")
|
||||
class PropertyBackedBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("loginEnabled reflects security flag")
|
||||
void loginEnabled() {
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
assertThat(appConfig.loginEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backendUrl falls back to localhost when unset")
|
||||
void backendUrlFallback() {
|
||||
assertThat(appConfig.getBackendUrl()).isEqualTo("http://localhost");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backendUrl returns configured value when present")
|
||||
void backendUrlConfigured() {
|
||||
applicationProperties.getSystem().setBackendUrl("https://api.example.com");
|
||||
assertThat(appConfig.getBackendUrl()).isEqualTo("https://api.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("languages bean returns configured languages list")
|
||||
void languages() {
|
||||
applicationProperties.getUi().setLanguages(List.of("en", "de"));
|
||||
assertThat(appConfig.languages()).containsExactly("en", "de");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("navBarText falls back to Stirling PDF when unset")
|
||||
void navBarTextFallback() {
|
||||
assertThat(appConfig.navBarText()).isEqualTo("Stirling PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("navBarText returns configured value")
|
||||
void navBarTextConfigured() {
|
||||
applicationProperties.getUi().setAppNameNavbar("My PDF");
|
||||
assertThat(appConfig.navBarText()).isEqualTo("My PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enableAlphaFunctionality reflects system flag")
|
||||
void alphaFunctionality() {
|
||||
applicationProperties.getSystem().setEnableAlphaFunctionality(true);
|
||||
assertThat(appConfig.enableAlphaFunctionality()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legal text beans return configured values")
|
||||
void legalBeans() {
|
||||
var legal = applicationProperties.getLegal();
|
||||
legal.setTermsAndConditions("terms");
|
||||
legal.setPrivacyPolicy("privacy");
|
||||
legal.setCookiePolicy("cookie");
|
||||
legal.setImpressum("impressum");
|
||||
legal.setAccessibilityStatement("a11y");
|
||||
assertThat(appConfig.termsAndConditions()).isEqualTo("terms");
|
||||
assertThat(appConfig.privacyPolicy()).isEqualTo("privacy");
|
||||
assertThat(appConfig.cookiePolicy()).isEqualTo("cookie");
|
||||
assertThat(appConfig.impressum()).isEqualTo("impressum");
|
||||
assertThat(appConfig.accessibilityStatement()).isEqualTo("a11y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsPrompt true when enableAnalytics null")
|
||||
void analyticsPrompt() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(null);
|
||||
assertThat(appConfig.analyticsPrompt()).isTrue();
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
assertThat(appConfig.analyticsPrompt()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsEnabled true when premium enabled regardless of system flag")
|
||||
void analyticsEnabledViaPremium() {
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
assertThat(appConfig.analyticsEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsEnabled reflects system flag when premium disabled")
|
||||
void analyticsEnabledViaSystem() {
|
||||
applicationProperties.getPremium().setEnabled(false);
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
assertThat(appConfig.analyticsEnabled()).isTrue();
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.FALSE);
|
||||
assertThat(appConfig.analyticsEnabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("scarf and posthog beans reflect derived flags")
|
||||
void scarfAndPosthog() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
applicationProperties.getSystem().setEnableScarf(Boolean.TRUE);
|
||||
applicationProperties.getSystem().setEnablePosthog(Boolean.TRUE);
|
||||
assertThat(appConfig.scarfEnabled()).isTrue();
|
||||
assertThat(appConfig.posthogEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uuid bean returns generated UUID")
|
||||
void uuidBean() {
|
||||
applicationProperties.getAutomaticallyGenerated().setUUID("abc-123");
|
||||
assertThat(appConfig.uuid()).isEqualTo("abc-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("typed config beans return live nested instances")
|
||||
void typedConfigBeans() {
|
||||
assertThat(appConfig.security()).isSameAs(applicationProperties.getSecurity());
|
||||
assertThat(appConfig.oAuth2())
|
||||
.isSameAs(applicationProperties.getSecurity().getOauth2());
|
||||
assertThat(appConfig.premium()).isSameAs(applicationProperties.getPremium());
|
||||
assertThat(appConfig.system()).isSameAs(applicationProperties.getSystem());
|
||||
assertThat(appConfig.datasource())
|
||||
.isSameAs(applicationProperties.getSystem().getDatasource());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Profile-default and environment beans")
|
||||
class ProfileAndEnvBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("default-profile license beans return community defaults")
|
||||
void licenseDefaults() {
|
||||
assertThat(appConfig.runningProOrHigher()).isFalse();
|
||||
assertThat(appConfig.runningEnterprise()).isFalse();
|
||||
assertThat(appConfig.licenseType()).isEqualTo("NORMAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("activeSecurity reflects classpath presence of SecurityConfiguration")
|
||||
void activeSecurity() {
|
||||
// Just exercise the branch; result depends on classpath, assert it does not throw.
|
||||
boolean present = appConfig.missingActiveSecurity();
|
||||
assertThat(present).isIn(true, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rateLimit parses system property")
|
||||
void rateLimitProperty() {
|
||||
String prev = System.getProperty("rateLimit");
|
||||
try {
|
||||
System.setProperty("rateLimit", "true");
|
||||
assertThat(appConfig.rateLimit()).isTrue();
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
System.clearProperty("rateLimit");
|
||||
} else {
|
||||
System.setProperty("rateLimit", prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runningInDocker false outside container")
|
||||
void runningInDocker() {
|
||||
// CI/test host is not a container with /.dockerenv.
|
||||
assertThat(appConfig.runningInDocker()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("configDirMounted defaults to true when not in docker")
|
||||
void configDirMounted() {
|
||||
assertThat(appConfig.isRunningInDockerWithConfig()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("directoryFilter accepts files and rejects processing dirs")
|
||||
void directoryFilter(@org.junit.jupiter.api.io.TempDir Path tempDir) throws Exception {
|
||||
Predicate<Path> filter = appConfig.processOnlyFiles();
|
||||
Path file = Files.createFile(tempDir.resolve("a.txt"));
|
||||
Path normalDir = Files.createDirectory(tempDir.resolve("normal"));
|
||||
Path processingDir = Files.createDirectory(tempDir.resolve("processing"));
|
||||
assertThat(filter.test(file)).isTrue();
|
||||
assertThat(filter.test(normalDir)).isTrue();
|
||||
assertThat(filter.test(processingDir)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("machineType returns Server-jar in plain test environment")
|
||||
void machineTypeServerJar() {
|
||||
assertThat(appConfig.determineMachineType()).isEqualTo("Server-jar");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("machineType returns a Client-* variant when BROWSER_OPEN set")
|
||||
void machineTypeClient() {
|
||||
env.setProperty("BROWSER_OPEN", "true");
|
||||
assertThat(appConfig.determineMachineType()).startsWith("Client-");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
class ConfigInitializerMoreTest {
|
||||
|
||||
private static final LoadSettings LOAD_SETTINGS =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
// Template after the enterpriseEdition -> premium rename.
|
||||
private static final String PREMIUM_TEMPLATE =
|
||||
"""
|
||||
premium:
|
||||
enabled: false
|
||||
key: 0000
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
""";
|
||||
|
||||
@Nested
|
||||
@DisplayName("migrateEnterpriseEditionToPremium")
|
||||
class EnterpriseMigration {
|
||||
|
||||
@Test
|
||||
@DisplayName("carries legacy enterpriseEdition values forward into premium block")
|
||||
void migratesLegacyEnterpriseValues() throws Exception {
|
||||
String legacy =
|
||||
"""
|
||||
enterpriseEdition:
|
||||
enabled: true
|
||||
key: ABC-123
|
||||
SSOAutoLogin: true
|
||||
CustomMetadata:
|
||||
autoUpdateMetadata: true
|
||||
author: alice
|
||||
creator: bob
|
||||
producer: carol
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
|
||||
|
||||
invokeMigrate(existing, template);
|
||||
|
||||
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("true");
|
||||
assertThat(template.getValueByExactKeyPath("premium", "key")).isEqualTo("ABC-123");
|
||||
assertThat(template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"))
|
||||
.isEqualTo("true");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium",
|
||||
"proFeatures",
|
||||
"customMetadata",
|
||||
"autoUpdateMetadata"))
|
||||
.isEqualTo("true");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"))
|
||||
.isEqualTo("alice");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "creator"))
|
||||
.isEqualTo("bob");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "producer"))
|
||||
.isEqualTo("carol");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no legacy enterpriseEdition block leaves template defaults intact")
|
||||
void noLegacyKeysIsNoOp() throws Exception {
|
||||
String noEnterprise =
|
||||
"""
|
||||
security:
|
||||
enableLogin: false
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, noEnterprise);
|
||||
|
||||
invokeMigrate(existing, template);
|
||||
|
||||
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("false");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"))
|
||||
.isEqualTo("username");
|
||||
}
|
||||
|
||||
private void invokeMigrate(YamlHelper yaml, YamlHelper template) throws Exception {
|
||||
var method =
|
||||
ConfigInitializer.class.getDeclaredMethod(
|
||||
"migrateEnterpriseEditionToPremium",
|
||||
YamlHelper.class,
|
||||
YamlHelper.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(new ConfigInitializer(), yaml, template);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ensureConfigExists - create branch (template absent on common classpath)")
|
||||
class EnsureConfigCreateBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("no settings file -> attempts create, fails fast when template missing")
|
||||
void createWithoutTemplateThrows(@TempDir Path tempDir) throws Exception {
|
||||
Path settings = tempDir.resolve("configs").resolve("settings.yml");
|
||||
Path custom = tempDir.resolve("configs").resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath)
|
||||
.thenReturn(settings.toString());
|
||||
mocked.when(InstallationPathConfig::getCustomSettingsPath)
|
||||
.thenReturn(custom.toString());
|
||||
|
||||
// settings.yml.template is packaged in the core module, not common, so the
|
||||
// create branch must surface a FileNotFoundException here.
|
||||
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
|
||||
.isInstanceOf(FileNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("short existing settings file is backed up before recreate attempt")
|
||||
void shortFileIsBackedUp(@TempDir Path tempDir) throws Exception {
|
||||
Path configDir = Files.createDirectories(tempDir.resolve("configs"));
|
||||
Path settings = configDir.resolve("settings.yml");
|
||||
Path custom = configDir.resolve("custom_settings.yml");
|
||||
// Fewer than MIN_SETTINGS_FILE_LINES (31) lines triggers the recreate path.
|
||||
Files.writeString(settings, "a: 1\nb: 2\n");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath)
|
||||
.thenReturn(settings.toString());
|
||||
mocked.when(InstallationPathConfig::getCustomSettingsPath)
|
||||
.thenReturn(custom.toString());
|
||||
|
||||
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
|
||||
.isInstanceOf(FileNotFoundException.class);
|
||||
}
|
||||
|
||||
// Original was moved to a timestamped .bak before the failed recreate.
|
||||
try (Stream<Path> files = Files.list(configDir)) {
|
||||
assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak")))
|
||||
.isTrue();
|
||||
}
|
||||
assertThat(Files.exists(settings)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
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.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
|
||||
import stirling.software.common.model.ApplicationProperties.ProcessExecutor.UnoServerEndpoint;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link RuntimePathConfig}. All of the resolution logic lives in the constructor,
|
||||
* so each test builds a real {@link ApplicationProperties} (a plain @Data POJO with sensible
|
||||
* defaults), constructs the config, and asserts on the exposed getters.
|
||||
*/
|
||||
class RuntimePathConfigTest {
|
||||
|
||||
/** The base path the production code derives from {@link InstallationPathConfig#getPath()}. */
|
||||
private static final String BASE_PATH = InstallationPathConfig.getPath();
|
||||
|
||||
private static ApplicationProperties newProperties() {
|
||||
return new ApplicationProperties();
|
||||
}
|
||||
|
||||
private static RuntimePathConfig build(ApplicationProperties properties) {
|
||||
return new RuntimePathConfig(properties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Pipeline directory resolution")
|
||||
class PipelinePaths {
|
||||
|
||||
@Test
|
||||
@DisplayName("Defaults to <basePath>/pipeline and derived sub-folders")
|
||||
void defaultPipelinePaths() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
String expectedPipeline = Path.of(BASE_PATH, "pipeline").toString();
|
||||
assertEquals(expectedPipeline, config.getPipelinePath());
|
||||
// Watched folders are resolved to an absolute, normalized path by the production code.
|
||||
assertEquals(
|
||||
Path.of(expectedPipeline, "watchedFolders")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.toString(),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of(expectedPipeline, "finishedFolders").toString(),
|
||||
config.getPipelineFinishedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of(expectedPipeline, "defaultWebUIConfigs").toString(),
|
||||
config.getPipelineDefaultWebUiConfigs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom pipelineDir overrides the default pipeline path")
|
||||
void customPipelineDir() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
pipeline.setPipelineDir("/custom/pipeline");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/custom/pipeline", config.getPipelinePath());
|
||||
// Sub-folders are derived from the (already-resolved) custom pipeline path.
|
||||
assertEquals(
|
||||
Path.of("/custom/pipeline", "finishedFolders").toString(),
|
||||
config.getPipelineFinishedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of("/custom/pipeline", "defaultWebUIConfigs").toString(),
|
||||
config.getPipelineDefaultWebUiConfigs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank pipelineDir falls back to the default")
|
||||
void blankPipelineDirFallsBackToDefault() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getSystem().getCustomPaths().getPipeline().setPipelineDir(" ");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals(Path.of(BASE_PATH, "pipeline").toString(), config.getPipelinePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom finished and webUI configs dirs override defaults")
|
||||
void customFinishedAndWebUiDirs() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
pipeline.setFinishedFoldersDir("/custom/finished");
|
||||
pipeline.setWebUIConfigsDir("/custom/webui");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/custom/finished", config.getPipelineFinishedFoldersPath());
|
||||
assertEquals("/custom/webui", config.getPipelineDefaultWebUiConfigs());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Watched folder resolution")
|
||||
class WatchedFolders {
|
||||
|
||||
@Test
|
||||
@DisplayName("Default watched folder is <pipeline>/watchedFolders and list has one entry")
|
||||
void defaultWatchedFolder() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
// Watched folders are resolved to an absolute, normalized path by the production code.
|
||||
String expected =
|
||||
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.toString();
|
||||
assertEquals(expected, config.getPipelineWatchedFoldersPath());
|
||||
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
|
||||
assertEquals(expected, config.getPipelineWatchedFoldersPaths().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy single watchedFoldersDir is used when no list is provided")
|
||||
void legacyWatchedFolder() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDir("relativeWatched");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
// Legacy paths are normalized to absolute.
|
||||
String expected = Path.of("relativeWatched").toAbsolutePath().normalize().toString();
|
||||
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
|
||||
assertEquals(expected, config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("New list config takes precedence over the legacy single dir")
|
||||
void listTakesPrecedenceOverLegacy() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
pipeline.setWatchedFoldersDir("legacyDir");
|
||||
pipeline.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("listDirA", "listDirB")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<String> paths = config.getPipelineWatchedFoldersPaths();
|
||||
assertEquals(2, paths.size());
|
||||
assertEquals(Path.of("listDirA").toAbsolutePath().normalize().toString(), paths.get(0));
|
||||
assertEquals(Path.of("listDirB").toAbsolutePath().normalize().toString(), paths.get(1));
|
||||
// The legacy value must NOT appear when the list is present.
|
||||
assertFalse(
|
||||
paths.contains(Path.of("legacyDir").toAbsolutePath().normalize().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Duplicate paths in the list are de-duplicated after normalization")
|
||||
void duplicatePathsAreDeduplicated() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(
|
||||
new ArrayList<>(Arrays.asList("dupDir", "dupDir", "otherDir")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<String> paths = config.getPipelineWatchedFoldersPaths();
|
||||
assertEquals(2, paths.size());
|
||||
assertEquals(Path.of("dupDir").toAbsolutePath().normalize().toString(), paths.get(0));
|
||||
assertEquals(Path.of("otherDir").toAbsolutePath().normalize().toString(), paths.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank and whitespace-only list entries are sanitized out")
|
||||
void blankListEntriesAreFiltered() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(
|
||||
new ArrayList<>(Arrays.asList(" ", "", "validDir", " ")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<String> paths = config.getPipelineWatchedFoldersPaths();
|
||||
assertEquals(1, paths.size());
|
||||
assertEquals(Path.of("validDir").toAbsolutePath().normalize().toString(), paths.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("List entries are trimmed before resolution")
|
||||
void listEntriesAreTrimmed() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList(" spacedDir ")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals(
|
||||
Path.of("spacedDir").toAbsolutePath().normalize().toString(),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("An all-blank list falls back to the legacy dir, then default")
|
||||
void allBlankListFallsBackToDefault() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("", " ")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
// sanitizePathList strips everything -> empty -> falls through to default watched
|
||||
// folder.
|
||||
// The default is also resolved to an absolute, normalized path by the production code.
|
||||
String expectedDefault =
|
||||
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.toString();
|
||||
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
|
||||
assertEquals(expectedDefault, config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("First watched folder path is always exposed via the singular getter")
|
||||
void singularGetterReturnsFirstEntry() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("firstDir", "secondDir")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals(
|
||||
config.getPipelineWatchedFoldersPaths().get(0),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of("firstDir").toAbsolutePath().normalize().toString(),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Operation tool path resolution")
|
||||
class OperationPaths {
|
||||
|
||||
@Test
|
||||
@DisplayName("Defaults to bare command names when not running in Docker")
|
||||
void defaultOperationPaths() {
|
||||
// The test host has no /.dockerenv, so the non-docker defaults apply.
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
assertEquals("weasyprint", config.getWeasyPrintPath());
|
||||
assertEquals("unoconvert", config.getUnoConvertPath());
|
||||
assertEquals("ebook-convert", config.getCalibrePath());
|
||||
assertEquals("ocrmypdf", config.getOcrMyPdfPath());
|
||||
assertEquals("soffice", config.getSOfficePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom operation paths override the defaults")
|
||||
void customOperationPaths() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Operations operations = properties.getSystem().getCustomPaths().getOperations();
|
||||
operations.setWeasyprint("/opt/custom/weasyprint");
|
||||
operations.setUnoconvert("/opt/custom/unoconvert");
|
||||
operations.setCalibre("/opt/custom/ebook-convert");
|
||||
operations.setOcrmypdf("/opt/custom/ocrmypdf");
|
||||
operations.setSoffice("/opt/custom/soffice");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/opt/custom/weasyprint", config.getWeasyPrintPath());
|
||||
assertEquals("/opt/custom/unoconvert", config.getUnoConvertPath());
|
||||
assertEquals("/opt/custom/ebook-convert", config.getCalibrePath());
|
||||
assertEquals("/opt/custom/ocrmypdf", config.getOcrMyPdfPath());
|
||||
assertEquals("/opt/custom/soffice", config.getSOfficePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank custom operation path falls back to the default")
|
||||
void blankOperationPathFallsBack() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getSystem().getCustomPaths().getOperations().setWeasyprint(" ");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("weasyprint", config.getWeasyPrintPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("A single custom path leaves the other operation paths at defaults")
|
||||
void partialOperationOverride() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getOperations()
|
||||
.setSoffice("/usr/local/soffice");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/usr/local/soffice", config.getSOfficePath());
|
||||
assertEquals("weasyprint", config.getWeasyPrintPath());
|
||||
assertEquals("unoconvert", config.getUnoConvertPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Tesseract data path resolution")
|
||||
class TessdataPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit tessdataDir config wins over env var and default")
|
||||
void configuredTessdataDirWins() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getSystem().setTessdataDir("/my/tessdata");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
// Config setting has the highest priority regardless of TESSDATA_PREFIX env state.
|
||||
assertEquals("/my/tessdata", config.getTessDataPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tessDataPath is never null even with no config")
|
||||
void tessDataPathNeverNull() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
// With no config setting, the value comes from TESSDATA_PREFIX or the hard default,
|
||||
// either of which is non-null.
|
||||
assertNotNull(config.getTessDataPath());
|
||||
assertFalse(config.getTessDataPath().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("UNO server endpoint resolution")
|
||||
class UnoServerEndpoints {
|
||||
|
||||
@Test
|
||||
@DisplayName("Auto mode builds one endpoint when session limit is unset (defaults to 1)")
|
||||
void autoSingleEndpointByDefault() {
|
||||
// Default ApplicationProperties: autoUnoServer = true, libreOfficeSessionLimit = 0 ->
|
||||
// 1.
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("127.0.0.1", endpoints.get(0).getHost());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Auto mode builds N endpoints on consecutive even ports")
|
||||
void autoMultipleEndpoints() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getProcessExecutor().getSessionLimit().setLibreOfficeSessionLimit(3);
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(3, endpoints.size());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
assertEquals(2005, endpoints.get(1).getPort());
|
||||
assertEquals(2007, endpoints.get(2).getPort());
|
||||
for (UnoServerEndpoint endpoint : endpoints) {
|
||||
assertEquals("127.0.0.1", endpoint.getHost());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Manual mode returns the configured (valid) endpoints")
|
||||
void manualEndpointsAreUsed() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
processExecutor.setAutoUnoServer(false);
|
||||
|
||||
UnoServerEndpoint endpoint = new UnoServerEndpoint();
|
||||
endpoint.setHost("10.0.0.5");
|
||||
endpoint.setPort(4000);
|
||||
processExecutor.setUnoServerEndpoints(new ArrayList<>(Arrays.asList(endpoint)));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("10.0.0.5", endpoints.get(0).getHost());
|
||||
assertEquals(4000, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Manual mode filters out endpoints with blank host or non-positive port")
|
||||
void manualEndpointsAreSanitized() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
processExecutor.setAutoUnoServer(false);
|
||||
|
||||
UnoServerEndpoint valid = new UnoServerEndpoint();
|
||||
valid.setHost("192.168.1.10");
|
||||
valid.setPort(5000);
|
||||
|
||||
UnoServerEndpoint blankHost = new UnoServerEndpoint();
|
||||
blankHost.setHost(" ");
|
||||
blankHost.setPort(5001);
|
||||
|
||||
UnoServerEndpoint badPort = new UnoServerEndpoint();
|
||||
badPort.setHost("192.168.1.11");
|
||||
badPort.setPort(0);
|
||||
|
||||
processExecutor.setUnoServerEndpoints(
|
||||
new ArrayList<>(Arrays.asList(valid, blankHost, badPort)));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("192.168.1.10", endpoints.get(0).getHost());
|
||||
assertEquals(5000, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Manual mode with no usable endpoints falls back to a single default endpoint")
|
||||
void manualModeNoEndpointsFallsBackToDefault() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
processExecutor.setAutoUnoServer(false);
|
||||
processExecutor.setUnoServerEndpoints(new ArrayList<>());
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("127.0.0.1", endpoints.get(0).getHost());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null processExecutor defaults to a single UNO endpoint")
|
||||
void nullProcessExecutorDefaultsToSingleEndpoint() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.setProcessExecutor(null);
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("127.0.0.1", endpoints.get(0).getHost());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("General contract")
|
||||
class GeneralContract {
|
||||
|
||||
@Test
|
||||
@DisplayName("getProperties returns the same instance passed to the constructor")
|
||||
void propertiesAccessorReturnsSameInstance() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertSame(properties, config.getProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("basePath matches InstallationPathConfig.getPath()")
|
||||
void basePathMatchesInstallationPath() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
assertEquals(BASE_PATH, config.getBasePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("All resolved path getters are non-null")
|
||||
void allPathsNonNull() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
assertNotNull(config.getPipelinePath());
|
||||
assertNotNull(config.getPipelineWatchedFoldersPath());
|
||||
assertNotNull(config.getPipelineWatchedFoldersPaths());
|
||||
assertNotNull(config.getPipelineFinishedFoldersPath());
|
||||
assertNotNull(config.getPipelineDefaultWebUiConfigs());
|
||||
assertNotNull(config.getWeasyPrintPath());
|
||||
assertNotNull(config.getUnoConvertPath());
|
||||
assertNotNull(config.getCalibrePath());
|
||||
assertNotNull(config.getOcrMyPdfPath());
|
||||
assertNotNull(config.getSOfficePath());
|
||||
assertNotNull(config.getTessDataPath());
|
||||
assertNotNull(config.getUnoServerEndpoints());
|
||||
assertTrue(config.getUnoServerEndpoints().size() >= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -2,7 +2,7 @@ package stirling.software.common.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -31,18 +31,33 @@ class ApplicationPropertiesLogicTest {
|
||||
assertTrue(sys.isAnalyticsEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageSigning_userListScope_defaultsToOrg_andIsSettable() {
|
||||
// Self-host backward-compat: scope must default to "org" (saas profile pins "team").
|
||||
ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing();
|
||||
|
||||
assertFalse(signing.isEnabled());
|
||||
assertEquals("org", signing.getUserListScope());
|
||||
|
||||
signing.setUserListScope("team");
|
||||
assertEquals("team", signing.getUserListScope());
|
||||
|
||||
// Reachable from the full tree as storage.signing.userListScope.
|
||||
assertEquals(
|
||||
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tempFileManagement_defaults_and_overrides() {
|
||||
Function<String, String> normalize = s -> Paths.get(s).normalize().toString();
|
||||
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
|
||||
ApplicationProperties.TempFileManagement tfm =
|
||||
new ApplicationProperties.TempFileManagement();
|
||||
|
||||
String expectedBase =
|
||||
Paths.get(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf")
|
||||
.toString();
|
||||
Path.of(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf").toString();
|
||||
assertEquals(expectedBase, tfm.getBaseTmpDir());
|
||||
|
||||
String expectedLibre = Paths.get(expectedBase, "libreoffice").toString();
|
||||
String expectedLibre = Path.of(expectedBase, "libreoffice").toString();
|
||||
assertEquals(expectedLibre, tfm.getLibreofficeDir());
|
||||
|
||||
tfm.setBaseTmpDir("/custom/base");
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package stirling.software.common.pdf;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.text.PageText;
|
||||
import stirling.software.jpdfium.text.Table;
|
||||
import stirling.software.jpdfium.text.TextChar;
|
||||
import stirling.software.jpdfium.text.TextLine;
|
||||
import stirling.software.jpdfium.text.TextWord;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link PdfMarkdownConverter} not covered by {@link
|
||||
* PdfMarkdownConverterTest}: the visible-for-testing column-range detector across a range of
|
||||
* geometries, the package-private extraction helpers, and the full conversion of the wrapped-cell
|
||||
* fixture (only run under a disabled accuracy test in the sibling suite).
|
||||
*/
|
||||
class PdfMarkdownConverterMoreTest {
|
||||
|
||||
@TempDir Path tmp;
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/** A word occupying [x, x+width] on baseline y; chars are synthetic so text length is real. */
|
||||
private static TextWord word(String text, float x, float width) {
|
||||
List<TextChar> chars = new ArrayList<>();
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
chars.add(
|
||||
new TextChar(
|
||||
i,
|
||||
text.charAt(i),
|
||||
x,
|
||||
0f,
|
||||
width / Math.max(1, text.length()),
|
||||
10f,
|
||||
"Helvetica",
|
||||
10f));
|
||||
}
|
||||
return new TextWord(chars, x, 0f, width, 10f);
|
||||
}
|
||||
|
||||
/** A single-line row built from the given words, spanning their full x-range. */
|
||||
private static TextLine row(float y, TextWord... words) {
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxX = -Float.MAX_VALUE;
|
||||
for (TextWord w : words) {
|
||||
minX = Math.min(minX, w.x());
|
||||
maxX = Math.max(maxX, w.x() + w.width());
|
||||
}
|
||||
return new TextLine(List.of(words), minX, y, maxX - minX, 10f);
|
||||
}
|
||||
|
||||
/** Copies a classpath fixture into the temp dir and returns its path. */
|
||||
private Path fixture(String name) throws IOException {
|
||||
Path dest = tmp.resolve(name);
|
||||
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + name)) {
|
||||
assertThat(in).as("fixture on classpath: " + name).isNotNull();
|
||||
Files.copy(in, dest);
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
// ---- findColumnRangesFromLines -----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("findColumnRangesFromLines")
|
||||
class ColumnRanges {
|
||||
|
||||
@Test
|
||||
@DisplayName("two well-separated bands are detected as two columns")
|
||||
void twoColumns() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 4; r++) {
|
||||
float y = 400f - r * 12f;
|
||||
rows.add(row(y, word("left", 50f, 40f), word("right", 190f, 40f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(2);
|
||||
// First band starts near 50, second near 190.
|
||||
assertThat(cols.get(0)[0]).isLessThan(cols.get(1)[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two bands within a narrow gutter merge into one column")
|
||||
void narrowGutterMerges() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 4; r++) {
|
||||
float y = 400f - r * 12f;
|
||||
// Gap of ~10pt is far below the merge threshold for 40pt-wide words.
|
||||
rows.add(row(y, word("aa", 50f, 40f), word("bb", 100f, 40f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single occupied band yields one column (trailing-band flush)")
|
||||
void singleColumn() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 3; r++) {
|
||||
rows.add(row(400f - r * 12f, word("word", 50f, 60f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
assertThat(cols.get(0)[0]).isCloseTo(50f, org.assertj.core.api.Assertions.within(2f));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rows with no words produce no columns")
|
||||
void noWordsNoColumns() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 3; r++) {
|
||||
rows.add(new TextLine(List.of(), 0f, 400f - r * 12f, 0f, 10f));
|
||||
}
|
||||
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(rows)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty row list produces no columns")
|
||||
void emptyInput() {
|
||||
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a sparsely-covered band below the support threshold is dropped")
|
||||
void sparseBandDropped() {
|
||||
// Five rows fill the left band; only one fills a far-right band, which is below the
|
||||
// 35%-of-rows support floor and so is not reported as a column.
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 5; r++) {
|
||||
rows.add(row(400f - r * 12f, word("left", 50f, 40f)));
|
||||
}
|
||||
rows.add(row(320f, word("left", 50f, 40f), word("rareoutlier", 400f, 60f)));
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- package-private extraction helpers ---------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extraction helpers")
|
||||
class ExtractionHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("extractAllPageText returns one PageText per page")
|
||||
void extractAllPageText() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<PageText> pages = new PdfMarkdownConverter().extractAllPageText(doc);
|
||||
assertThat(pages).isNotNull();
|
||||
assertThat(pages).hasSize(doc.pageCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTables returns a non-null list for the first page")
|
||||
void extractTables() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<Table> tables = new PdfMarkdownConverter().extractTables(doc, 0);
|
||||
assertThat(tables).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renderTables maps each extracted table to a markdown string")
|
||||
void renderTables() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
PdfMarkdownConverter converter = new PdfMarkdownConverter();
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<Table> tables = converter.extractTables(doc, 0);
|
||||
List<String> rendered = converter.renderTables(tables);
|
||||
assertThat(rendered).isNotNull();
|
||||
assertThat(rendered).hasSameSizeAs(tables);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renderTables on an empty table list returns an empty list")
|
||||
void renderTablesEmpty() {
|
||||
assertThat(new PdfMarkdownConverter().renderTables(List.of())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- full conversion of additional fixtures -----------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convert full pipeline")
|
||||
class ConvertPipeline {
|
||||
|
||||
@Test
|
||||
@DisplayName("wrapped-cell expense report converts without throwing and yields content")
|
||||
void wrappedCellFixture() throws IOException {
|
||||
Path pdf = fixture("wrapped-cell-test_expense-report.pdf");
|
||||
String md;
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
md = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
assertThat(md).isNotNull();
|
||||
assertThat(md).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converting a fixture twice is deterministic")
|
||||
void deterministic() throws IOException {
|
||||
Path pdf = fixture("multi-column-test_lorem.pdf");
|
||||
String first;
|
||||
String second;
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
first = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
second = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the many-tables stress fixture converts without throwing")
|
||||
void manyTablesFixture() throws IOException {
|
||||
Path pdf = fixture("many-tables-test_stress.pdf");
|
||||
assertDoesNotThrow(
|
||||
() -> {
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
return new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.pdf;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.jpdfium.text.Table;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TableRenderer}. Tables are built directly from the {@link Table} record so
|
||||
* the renderer can be exercised without any PDF parsing, fixtures, or native calls.
|
||||
*/
|
||||
class TableRendererTest {
|
||||
|
||||
/** Builds a Table from raw rows; geometry is irrelevant to rendering so it is set to zero. */
|
||||
private static Table table(List<List<String>> rows) {
|
||||
return new Table(rows, 0f, 0f, 0f, 0f);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Degenerate tables")
|
||||
class Degenerate {
|
||||
|
||||
@Test
|
||||
@DisplayName("zero rows renders the empty string")
|
||||
void zeroRows() {
|
||||
assertThat(TableRenderer.render(table(List.of()))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single row with one column has no separator and is a plain line")
|
||||
void singleRowOneColumn() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("only"))));
|
||||
assertThat(md).isEqualTo("only");
|
||||
assertThat(md).doesNotContain("|");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single row with several columns becomes newline-separated plain lines")
|
||||
void singleRowManyColumns() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("a", "b", "c"))));
|
||||
// No separator row is possible with a single row, so cells are emitted as lines.
|
||||
assertThat(md).isEqualTo("a\nb\nc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-row cell content is trimmed and escaped")
|
||||
void singleRowTrimsAndEscapes() {
|
||||
String md = TableRenderer.render(table(List.of(List.of(" a|b "))));
|
||||
assertThat(md).isEqualTo("a\\|b");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("GFM rendering")
|
||||
class GfmRendering {
|
||||
|
||||
@Test
|
||||
@DisplayName("two rows produce a header, a separator and a data row")
|
||||
void headerSeparatorData() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(List.of(List.of("Name", "Age"), List.of("Alice", "30"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(3);
|
||||
assertThat(lines[0]).startsWith("|").contains("Name").contains("Age");
|
||||
// Separator row is made only of pipes and dashes.
|
||||
assertThat(lines[1].chars().allMatch(c -> c == '|' || c == '-')).isTrue();
|
||||
assertThat(lines[2]).contains("Alice").contains("30");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("column widths grow to fit the widest cell in each column")
|
||||
void columnWidthsFitContent() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(
|
||||
List.of(
|
||||
List.of("h", "header2"),
|
||||
List.of("averylongvalue", "x"))));
|
||||
String[] lines = md.split("\n");
|
||||
// Every rendered row (header, separator, data) is the same total width.
|
||||
int width = lines[0].length();
|
||||
for (String line : lines) {
|
||||
assertThat(line.length()).isEqualTo(width);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("minimum column width of three dashes is honoured for tiny cells")
|
||||
void minimumWidthThree() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("a", "b"), List.of("c", "d"))));
|
||||
String separator = md.split("\n")[1];
|
||||
// Each column is padded to a minimum of 3, fenced by a dash either side: |-----|-----|.
|
||||
assertThat(separator).isEqualTo("|-----|-----|");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pipe characters in cells are escaped in every rendered row")
|
||||
void escapesPipes() {
|
||||
String md =
|
||||
TableRenderer.render(table(List.of(List.of("a|b", "c"), List.of("d", "e|f"))));
|
||||
// Two literal pipes escaped; the structural pipes are not.
|
||||
assertThat(md).contains("a\\|b").contains("e\\|f");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cells are trimmed before measuring and rendering")
|
||||
void trimsCells() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(List.of(List.of(" Name ", " Age "), List.of("Al", "30"))));
|
||||
assertThat(md).contains("| Name").contains("Age ");
|
||||
assertThat(md).doesNotContain(" Name ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("three rows emit two data rows after the separator")
|
||||
void multipleDataRows() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(
|
||||
List.of(
|
||||
List.of("c1", "c2"),
|
||||
List.of("a", "b"),
|
||||
List.of("x", "y"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(4);
|
||||
assertThat(lines[2]).contains("a").contains("b");
|
||||
assertThat(lines[3]).contains("x").contains("y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a short trailing row is padded out to the column count from asGrid")
|
||||
void shortRowPaddedByGrid() {
|
||||
// colCount comes from the first row; a shorter later row is padded with empty cells by
|
||||
// Table.asGrid, so rendering must not throw and the grid stays rectangular.
|
||||
String md =
|
||||
TableRenderer.render(table(List.of(List.of("a", "b", "c"), List.of("only"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(3);
|
||||
int width = lines[0].length();
|
||||
for (String line : lines) {
|
||||
assertThat(line.length()).isEqualTo(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.io.MemoryUsageSetting;
|
||||
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
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.io.TempDir;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class CustomPDFDocumentFactoryMoreTest {
|
||||
|
||||
private CustomPDFDocumentFactory factory;
|
||||
private byte[] basePdfBytes;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
try (InputStream is = getClass().getResourceAsStream("/example.pdf")) {
|
||||
basePdfBytes = is.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("null-argument guards")
|
||||
class NullGuards {
|
||||
|
||||
@Test
|
||||
@DisplayName("each load overload rejects null with IllegalArgumentException")
|
||||
void nullArguments() {
|
||||
assertThatThrownBy(() -> factory.load((File) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((Path) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((byte[]) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((InputStream) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((InputStream) null, "pw"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cache strategy selection (public overload)")
|
||||
class CacheStrategy {
|
||||
|
||||
@Test
|
||||
@DisplayName("getStreamCacheFunction returns a non-null function for each size band")
|
||||
void cacheFunctionPerBand() {
|
||||
StreamCacheCreateFunction small = factory.getStreamCacheFunction(1024);
|
||||
StreamCacheCreateFunction mixed = factory.getStreamCacheFunction(20L * 1024 * 1024);
|
||||
StreamCacheCreateFunction large = factory.getStreamCacheFunction(60L * 1024 * 1024);
|
||||
assertThat(small).isNotNull();
|
||||
assertThat(mixed).isNotNull();
|
||||
assertThat(large).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("create and round-trip helpers")
|
||||
class CreateAndRoundTrip {
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocument(MemoryUsageSetting) sets default metadata")
|
||||
void createWithMemorySetting() throws IOException {
|
||||
PdfMetadataService svc = mock(PdfMetadataService.class);
|
||||
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
|
||||
try (PDDocument doc = f.createNewDocument(MemoryUsageSetting.setupMainMemoryOnly())) {
|
||||
assertThat(doc).isNotNull();
|
||||
verify(svc).setDefaultMetadata(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loadToBytes(byte[]) round-trips a loadable PDF")
|
||||
void loadToBytesFromArray() throws IOException {
|
||||
byte[] out = factory.loadToBytes(basePdfBytes);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocumentBasedOnOldDocument(byte[]) produces a fresh document")
|
||||
void newDocFromOldBytes() throws IOException {
|
||||
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(basePdfBytes)) {
|
||||
assertThat(doc).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocumentBasedOnOldDocument(File) produces a fresh document")
|
||||
void newDocFromOldFile(@TempDir Path tempDir) throws IOException {
|
||||
File f = Files.write(tempDir.resolve("old.pdf"), basePdfBytes).toFile();
|
||||
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(f)) {
|
||||
assertThat(doc).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("read-only and password handling")
|
||||
class ReadOnlyAndPassword {
|
||||
|
||||
@Test
|
||||
@DisplayName("read-only load from file skips post-processing")
|
||||
void readOnlyFromFile(@TempDir Path tempDir) throws IOException {
|
||||
PdfMetadataService svc = mock(PdfMetadataService.class);
|
||||
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
|
||||
File file = Files.write(tempDir.resolve("ro.pdf"), basePdfBytes).toFile();
|
||||
try (PDDocument doc = f.load(file, true)) {
|
||||
assertThat(doc).isNotNull();
|
||||
org.mockito.Mockito.verify(svc, org.mockito.Mockito.never())
|
||||
.setDefaultMetadata(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encrypted PDF is decrypted on the default (non-read-only) load path")
|
||||
void encryptedPdfDecrypted() throws IOException {
|
||||
byte[] encrypted = buildEncryptedPdf("ownerpw", "userpw");
|
||||
// load(InputStream, password) drives removePassword + setAllSecurityToBeRemoved so the
|
||||
// returned document can be re-saved with no password set.
|
||||
byte[] decryptedSaved;
|
||||
try (PDDocument doc =
|
||||
factory.load(new ByteArrayInputStream(encrypted), "userpw", false)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
decryptedSaved = factory.saveToBytes(doc);
|
||||
}
|
||||
// Re-loading with no password proves security was stripped.
|
||||
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(decryptedSaved)) {
|
||||
assertThat(reloaded.isEncrypted()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile with positive small size uses byte[] path")
|
||||
void smallMultipartLoadsViaBytes() throws IOException {
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "s.pdf", MediaType.APPLICATION_PDF_VALUE, basePdfBytes);
|
||||
try (PDDocument doc = factory.load(multipart)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildEncryptedPdf(String ownerPw, String userPw) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
AccessPermission ap = new AccessPermission();
|
||||
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPw, userPw, ap);
|
||||
spp.setEncryptionKeyLength(128);
|
||||
doc.protect(spp);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
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.io.TempDir;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
|
||||
import stirling.software.common.service.FileStorage.StoredFile;
|
||||
|
||||
class FileStorageMoreTest {
|
||||
|
||||
@TempDir Path storageDir;
|
||||
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileStorage =
|
||||
new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(storageDir.toString()),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeInputStream / getFileSize / retrieveInputStream")
|
||||
class StreamAndSize {
|
||||
|
||||
@Test
|
||||
@DisplayName("storeInputStream returns id and exact byte size")
|
||||
void storeInputStreamReturnsSize() throws IOException {
|
||||
byte[] payload = "twelve bytes".getBytes(StandardCharsets.UTF_8);
|
||||
StoredFile stored =
|
||||
fileStorage.storeInputStream(new ByteArrayInputStream(payload), "in.bin");
|
||||
assertThat(stored.fileId()).isNotBlank();
|
||||
assertThat(stored.size()).isEqualTo(payload.length);
|
||||
assertThat(fileStorage.getFileSize(stored.fileId())).isEqualTo(payload.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retrieveInputStream yields the stored content")
|
||||
void retrieveInputStreamContent() throws IOException {
|
||||
byte[] payload = "stream-me".getBytes(StandardCharsets.UTF_8);
|
||||
String id = fileStorage.storeBytes(payload, "s.bin");
|
||||
try (InputStream in = fileStorage.retrieveInputStream(id)) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeFile fast path")
|
||||
class FastPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("file-backed MultipartFile is stored via the Resource fast path")
|
||||
void fileBackedResourceStored(@TempDir Path src) throws IOException {
|
||||
byte[] payload = "file-backed-content".getBytes(StandardCharsets.UTF_8);
|
||||
Path onDisk = Files.write(src.resolve("upload.pdf"), payload);
|
||||
|
||||
// A MultipartFile whose getResource() reports isFile()=true exercises the
|
||||
// file-to-file copy branch in storeFile.
|
||||
MultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "upload.pdf", MediaType.APPLICATION_PDF_VALUE, payload) {
|
||||
@Override
|
||||
public org.springframework.core.io.Resource getResource() {
|
||||
return new FileSystemResource(onDisk);
|
||||
}
|
||||
};
|
||||
|
||||
String id = fileStorage.storeFile(multipart);
|
||||
assertThat(id).isNotBlank();
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-memory MultipartFile falls back to the stream copy path")
|
||||
void inMemoryFallback() throws IOException {
|
||||
byte[] payload = "memory-content".getBytes(StandardCharsets.UTF_8);
|
||||
MultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "m.pdf", MediaType.APPLICATION_PDF_VALUE, payload);
|
||||
String id = fileStorage.storeFile(multipart);
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeFromStreamingBody")
|
||||
class StreamingBody {
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path streams body to storage")
|
||||
void happyPath() throws IOException {
|
||||
byte[] payload = "streamed-body-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
StreamingResponseBody body = out -> out.write(payload);
|
||||
String id = fileStorage.storeFromStreamingBody(body, "body.bin");
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writer IOException propagates and leaves no lingering file")
|
||||
void writerErrorPropagatesAndCleansUp() throws IOException {
|
||||
long before = countFiles();
|
||||
StreamingResponseBody body =
|
||||
out -> {
|
||||
out.write("partial".getBytes(StandardCharsets.UTF_8));
|
||||
throw new IOException("boom mid-write");
|
||||
};
|
||||
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
assertThat(countFiles()).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unchecked writer failure is wrapped as IOException")
|
||||
void uncheckedWriterErrorWrapped() {
|
||||
StreamingResponseBody body =
|
||||
out -> {
|
||||
throw new IllegalStateException("unchecked boom");
|
||||
};
|
||||
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad2.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
private long countFiles() throws IOException {
|
||||
try (var s = Files.list(storageDir)) {
|
||||
return s.count();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,53 @@ class InternalApiClientTest {
|
||||
servletContext, userService, tempFileManager, environment, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postTagsRequestAsAutomation() throws Exception {
|
||||
// Every InternalApiClient.post() caller is a parent automation flow dispatching a child
|
||||
// tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means
|
||||
// the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of
|
||||
// the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run
|
||||
// bills as AUTOMATION, not AI. The header value is the literal string "true" because the
|
||||
// interceptor compares case-insensitively-trimmed against that token.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp");
|
||||
TempFile tempFile = mock(TempFile.class);
|
||||
when(tempFile.getPath()).thenReturn(tempPath);
|
||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
||||
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
|
||||
|
||||
HttpHeaders[] captured = {null};
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
HttpEntity<?> entity = inv.getArgument(0);
|
||||
captured[0] = entity.getHeaders();
|
||||
return (RequestCallback) req -> {};
|
||||
});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
InternalApiClient mockedClient = newClient();
|
||||
mockedClient.post("/api/v1/general/merge-pdfs", body);
|
||||
|
||||
assertNotNull(captured[0]);
|
||||
assertEquals(
|
||||
"true",
|
||||
captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER),
|
||||
"Sub-step dispatch must carry the automation marker header");
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postDoesNotForceContentType() throws Exception {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/** Additional coverage for JobExecutorService branches not exercised by JobExecutorServiceTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobExecutorServiceMoreTest {
|
||||
|
||||
private JobExecutorService service;
|
||||
|
||||
@Mock private TaskManager taskManager;
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
@Mock private JobQueue jobQueue;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// request is null on purpose to exercise the request==null guard.
|
||||
service =
|
||||
new JobExecutorService(
|
||||
taskManager, fileStorage, null, resourceMonitor, jobQueue, 30000L, "30m");
|
||||
}
|
||||
|
||||
/** Concrete validation exception so we can drive the BaseValidationException rethrow branch. */
|
||||
private static class TestValidationException extends ExceptionUtils.BaseValidationException {
|
||||
TestValidationException(String message) {
|
||||
super(message, "E999");
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete app exception so we can drive the BaseAppException rethrow branch. */
|
||||
private static class TestAppException extends ExceptionUtils.BaseAppException {
|
||||
TestAppException(String message) {
|
||||
super(message, null, "E998");
|
||||
}
|
||||
}
|
||||
|
||||
/** Bean exposing getFileId/getOriginalFilename/getContentType for the reflection branch. */
|
||||
public static class FileIdBean {
|
||||
private final String fileId;
|
||||
private final String originalFilename;
|
||||
private final String contentType;
|
||||
|
||||
FileIdBean(String fileId, String originalFilename, String contentType) {
|
||||
this.fileId = fileId;
|
||||
this.originalFilename = originalFilename;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFileId() {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FileIdBean{fileId=" + fileId + "}";
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronous error mapping")
|
||||
class SyncErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("IllegalArgumentException is rethrown, not wrapped in a 500 body")
|
||||
void illegalArgumentRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new IllegalArgumentException("bad input");
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("bad input");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cause of BaseValidationException is rethrown")
|
||||
void validationCauseRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException(new TestValidationException("invalid"));
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(ExceptionUtils.BaseValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cause of BaseAppException is rethrown")
|
||||
void appCauseRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException(new TestAppException("app error"));
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(ExceptionUtils.BaseAppException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronous result handling")
|
||||
class SyncResults {
|
||||
|
||||
@Test
|
||||
@DisplayName("byte[] result becomes a PDF attachment response")
|
||||
void byteArrayBecomesAttachment() {
|
||||
byte[] payload = "pdf-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> payload);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo(payload);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("result.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile result is streamed back with its own content type")
|
||||
void multipartBecomesResponse() {
|
||||
MultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"f", "orig.txt", MediaType.TEXT_PLAIN_VALUE, "hi".getBytes());
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> file);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_PLAIN);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("orig.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ResponseEntity result is returned verbatim")
|
||||
void responseEntityReturnedVerbatim() {
|
||||
ResponseEntity<String> inner = ResponseEntity.status(HttpStatus.ACCEPTED).body("ok");
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> inner);
|
||||
assertThat(response).isSameAs(inner);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("asynchronous error handling")
|
||||
class AsyncErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("a thrown exception is recorded via TaskManager.setError")
|
||||
void asyncErrorRecorded() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException("async boom");
|
||||
};
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, work);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
verify(taskManager, timeout(5000)).setError(anyString(), eq("async boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job that exceeds its timeout is recorded as timed out")
|
||||
void asyncTimeoutRecorded() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond the 1ms timeout
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
// 1ms custom timeout, async, non-queueable.
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, work, 1L, false, 10);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
verify(taskManager, timeout(5000)).setError(anyString(), eq("Job timed out"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processJobResult branches (via async execution)")
|
||||
class ProcessJobResult {
|
||||
|
||||
@Test
|
||||
@DisplayName("raw byte[] result is stored and recorded as a file")
|
||||
void rawBytesStored() throws Exception {
|
||||
byte[] payload = "raw".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileStorage.storeBytes(any(byte[].class), eq("result.pdf")))
|
||||
.thenReturn("bytes-id");
|
||||
|
||||
service.runJobGeneric(true, () -> payload);
|
||||
|
||||
verify(fileStorage, timeout(5000)).storeBytes(any(byte[].class), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("bytes-id"),
|
||||
eq("result.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity<byte[]> is stored with the filename from headers")
|
||||
void responseEntityBytesStored() throws Exception {
|
||||
byte[] payload = "rebytes".getBytes(StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDisposition(
|
||||
ContentDisposition.formData().name("a").filename("out.pdf").build());
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(payload, headers, HttpStatus.OK);
|
||||
when(fileStorage.storeBytes(any(byte[].class), eq("out.pdf"))).thenReturn("re-id");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("re-id"),
|
||||
eq("out.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity<StreamingResponseBody> is stored via storeFromStreamingBody")
|
||||
void responseEntityStreamingStored() throws Exception {
|
||||
StreamingResponseBody body = out -> out.write("stream".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
when(fileStorage.storeFromStreamingBody(any(StreamingResponseBody.class), anyString()))
|
||||
.thenReturn("stream-id");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(fileStorage, timeout(5000))
|
||||
.storeFromStreamingBody(any(StreamingResponseBody.class), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("stream-id"), eq("result.pdf"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity body exposing getFileId is recorded via reflection")
|
||||
void responseEntityFileIdBean() {
|
||||
FileIdBean bean = new FileIdBean("bean-file", "bean.pdf", "text/custom");
|
||||
Supplier<Object> work = () -> ResponseEntity.ok(bean);
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("bean-file"), eq("bean.pdf"), eq("text/custom"));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain ResponseEntity body without fileId is stored as a generic result")
|
||||
void responseEntityPlainBody() {
|
||||
Supplier<Object> work = () -> ResponseEntity.ok("plain-string");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000)).setResult(anyString(), eq("plain-string"));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile result is stored via storeFile")
|
||||
void multipartStored() throws Exception {
|
||||
MultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"f", "m.pdf", MediaType.APPLICATION_PDF_VALUE, "m".getBytes());
|
||||
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("mp-id");
|
||||
|
||||
service.runJobGeneric(true, () -> file);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("mp-id"),
|
||||
eq("m.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain object result exposing getFileId is recorded via reflection")
|
||||
void plainObjectFileIdBean() {
|
||||
FileIdBean bean = new FileIdBean("plain-bean", "p.pdf", "app/p");
|
||||
|
||||
service.runJobGeneric(true, () -> bean);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("plain-bean"), eq("p.pdf"), eq("app/p"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a generic non-file object is stored via setResult")
|
||||
void genericObjectStored() {
|
||||
service.runJobGeneric(true, () -> "just-text");
|
||||
verify(taskManager, timeout(5000)).setResult(anyString(), eq("just-text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a storage failure is recorded as an error on the task")
|
||||
void storageFailureRecordsError() throws Exception {
|
||||
byte[] payload = "x".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileStorage.storeBytes(any(byte[].class), anyString()))
|
||||
.thenThrow(new java.io.IOException("disk full"));
|
||||
|
||||
service.runJobGeneric(true, () -> payload);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setError(anyString(), org.mockito.ArgumentMatchers.contains("disk full"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("queued execution")
|
||||
class QueuedExecution {
|
||||
|
||||
@Test
|
||||
@DisplayName("queued wrapped work stores its result through processJobResult on success")
|
||||
void queuedWorkSuccess() {
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
// Capture the wrapped supplier so we can run it as the queue would.
|
||||
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
|
||||
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
|
||||
.thenReturn(new CompletableFuture<>());
|
||||
|
||||
ResponseEntity<?> response =
|
||||
service.runJobGeneric(true, () -> "queued-ok", 5000, true, 80);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
|
||||
// Execute the wrapped work and assert it routed the result to TaskManager.
|
||||
Object result = workCaptor.getValue().get();
|
||||
assertThat(result).isEqualTo("queued-ok");
|
||||
verify(taskManager).setResult(anyString(), eq("queued-ok"));
|
||||
verify(taskManager).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("queued wrapped work records and rethrows on failure")
|
||||
void queuedWorkFailure() {
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
|
||||
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
|
||||
.thenReturn(new CompletableFuture<>());
|
||||
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new RuntimeException("queued-boom");
|
||||
};
|
||||
service.runJobGeneric(true, failing, 5000, true, 80);
|
||||
|
||||
assertThatThrownBy(() -> workCaptor.getValue().get())
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("queued-boom");
|
||||
verify(taskManager).setError(anyString(), eq("queued-boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job is not queued when it is synchronous even if queueable")
|
||||
void syncJobNeverQueued() {
|
||||
// queueable=true but async=false -> shouldQueue is false, runs inline.
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> "inline", 0, true, 90);
|
||||
assertThat(response.getBody()).isEqualTo("inline");
|
||||
verify(jobQueue, org.mockito.Mockito.never())
|
||||
.queueJob(anyString(), anyInt(), any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("job ownership scoping")
|
||||
class JobOwnership {
|
||||
|
||||
@Test
|
||||
@DisplayName("scoped job key and owner come from JobOwnershipService when present")
|
||||
void scopedKeyUsed() {
|
||||
JobOwnershipService ownership = org.mockito.Mockito.mock(JobOwnershipService.class);
|
||||
when(ownership.createScopedJobKey(anyString())).thenReturn("user1:scoped");
|
||||
lenient().when(ownership.getCurrentUserId()).thenReturn(Optional.of("user1"));
|
||||
ReflectionTestUtils.setField(service, "jobOwnershipService", ownership);
|
||||
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, () -> "owned");
|
||||
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
|
||||
assertThat(jobResponse.getJobId()).isEqualTo("user1:scoped");
|
||||
verify(taskManager).createTask("user1:scoped");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("session timeout parsing")
|
||||
class SessionTimeoutParsing {
|
||||
|
||||
private long parse(String value) {
|
||||
JobExecutorService s =
|
||||
new JobExecutorService(
|
||||
taskManager,
|
||||
fileStorage,
|
||||
null,
|
||||
resourceMonitor,
|
||||
jobQueue,
|
||||
999_999_999L,
|
||||
value);
|
||||
return (long) ReflectionTestUtils.getField(s, "effectiveTimeoutMs");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("seconds, hours and days units are parsed")
|
||||
void parsesUnits() {
|
||||
assertThat(parse("45s")).isEqualTo(45_000L);
|
||||
assertThat(parse("2h")).isEqualTo(2L * 60 * 60 * 1000);
|
||||
assertThat(parse("1d")).isEqualTo(24L * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unrecognised unit defaults to minutes")
|
||||
void unknownUnitDefaultsToMinutes() {
|
||||
assertThat(parse("5x")).isEqualTo(5L * 60 * 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/empty and unparseable values fall back to 30 minutes")
|
||||
void fallbackToThirtyMinutes() {
|
||||
long thirtyMin = 30L * 60 * 1000;
|
||||
assertThat(parse("")).isEqualTo(thirtyMin);
|
||||
assertThat(parse("garbage")).isEqualTo(thirtyMin);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sync timeout")
|
||||
class SyncTimeout {
|
||||
|
||||
@Test
|
||||
@DisplayName("a synchronous job that exceeds its timeout returns a 500 with a timeout body")
|
||||
void syncTimeoutReturns500() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond 1ms timeout
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, work, 1L);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> body = (Map<String, String>) response.getBody();
|
||||
assertThat(body.get("error")).contains("timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
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 org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
/** Additional coverage for JobQueue branches not exercised by JobQueueTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobQueueMoreTest {
|
||||
|
||||
private JobQueue jobQueue;
|
||||
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
|
||||
private final AtomicReference<ResourceStatus> statusRef =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient()
|
||||
.when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt()))
|
||||
.thenReturn(10);
|
||||
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
|
||||
jobQueue = new JobQueue(resourceMonitor);
|
||||
}
|
||||
|
||||
private void invokeProcessQueue() {
|
||||
ReflectionTestUtils.invokeMethod(jobQueue, "processQueue");
|
||||
}
|
||||
|
||||
// Bounded wait: block up to 5s for the queued job's future to settle on the executor.
|
||||
private static void awaitDone(CompletableFuture<?> future) {
|
||||
try {
|
||||
future.handle((r, e) -> null).get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new AssertionError("future did not complete within 5s", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SmartLifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("start/stop toggles running and start is idempotent")
|
||||
void startStopToggle() {
|
||||
assertThat(jobQueue.isRunning()).isFalse();
|
||||
|
||||
jobQueue.start();
|
||||
assertThat(jobQueue.isRunning()).isTrue();
|
||||
|
||||
// Second start is a no-op (already running).
|
||||
jobQueue.start();
|
||||
assertThat(jobQueue.isRunning()).isTrue();
|
||||
|
||||
jobQueue.stop();
|
||||
assertThat(jobQueue.isRunning()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("phase and auto-startup expose lifecycle ordering")
|
||||
void phaseAndAutoStartup() {
|
||||
assertThat(jobQueue.getPhase()).isEqualTo(10);
|
||||
assertThat(jobQueue.isAutoStartup()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stop completes any still-pending futures exceptionally")
|
||||
void stopCompletesPendingFutures() {
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("pending", 50, () -> "x", 1000);
|
||||
assertThat(future.isDone()).isFalse();
|
||||
|
||||
// Drive shutdown without starting the scheduler so no processor races us to the job.
|
||||
jobQueue.stop();
|
||||
|
||||
assertThat(future).isCompletedExceptionally();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("queueJob capacity")
|
||||
class QueueCapacity {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a job when the queue is full")
|
||||
void rejectsWhenFull() {
|
||||
// Capacity-1 queue whose timed offer rejects instantly (no 5s block) when full.
|
||||
BlockingQueue<Object> instaReject =
|
||||
new LinkedBlockingQueue<>(1) {
|
||||
@Override
|
||||
public boolean offer(Object e, long timeout, TimeUnit unit) {
|
||||
return super.offer(e);
|
||||
}
|
||||
};
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", instaReject);
|
||||
jobQueue.queueJob("first", 50, () -> "a", 1000);
|
||||
|
||||
CompletableFuture<ResponseEntity<?>> rejected =
|
||||
jobQueue.queueJob("second", 50, () -> "b", 1000);
|
||||
|
||||
assertThat(rejected).isCompletedExceptionally();
|
||||
assertThat(jobQueue.getRejectedJobs()).isEqualTo(1);
|
||||
assertThat(jobQueue.isJobQueued("second")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getQueueCapacity reflects remaining capacity plus current size")
|
||||
void getQueueCapacityReports() {
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(5));
|
||||
jobQueue.queueJob("c1", 50, () -> "a", 1000);
|
||||
assertThat(jobQueue.getQueueCapacity()).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("job position")
|
||||
class JobPosition {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 for the first queued job and -1 for an unknown job")
|
||||
void positionAndUnknown() {
|
||||
jobQueue.queueJob("p1", 50, () -> "a", 1000);
|
||||
jobQueue.queueJob("p2", 50, () -> "b", 1000);
|
||||
|
||||
assertThat(jobQueue.getJobPosition("p1")).isEqualTo(0);
|
||||
assertThat(jobQueue.getJobPosition("p2")).isEqualTo(1);
|
||||
assertThat(jobQueue.getJobPosition("missing")).isEqualTo(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cancelJob")
|
||||
class CancelJob {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when the job id is unknown")
|
||||
void cancelUnknownReturnsFalse() {
|
||||
assertThat(jobQueue.cancelJob("nope")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processQueue")
|
||||
class ProcessQueue {
|
||||
|
||||
@Test
|
||||
@DisplayName("does nothing when shutting down")
|
||||
void noopWhenShuttingDown() {
|
||||
jobQueue.queueJob("s1", 50, () -> "a", 1000);
|
||||
ReflectionTestUtils.setField(jobQueue, "shuttingDown", true);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
// Still queued: the shutdown guard returned before polling.
|
||||
assertThat(jobQueue.isJobQueued("s1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("delays execution while the system is under critical load")
|
||||
void delaysUnderCriticalLoad() {
|
||||
statusRef.set(ResourceStatus.CRITICAL);
|
||||
jobQueue.queueJob("crit", 50, () -> "a", 1000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
// Critical load: job remains queued, nothing executed.
|
||||
assertThat(jobQueue.isJobQueued("crit")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("executes a queued job and completes its future when resources are OK")
|
||||
void executesWhenOk() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("ok", 50, () -> "done", 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(jobQueue.isJobQueued("ok")).isFalse();
|
||||
assertThat(future).isCompleted();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job past the max wait time still executes and adds a timeout note")
|
||||
void overdueJobExecutesAndNotes() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
ReflectionTestUtils.setField(jobQueue, "maxWaitTimeMs", 1L);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("overdue", 50, () -> "late-done", 5000);
|
||||
|
||||
// Backdate the queuedAt so wait-time exceeds maxWaitTimeMs.
|
||||
backdateQueuedAt("overdue");
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future).isCompleted();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void backdateQueuedAt(String jobId) {
|
||||
var jobMap =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(jobQueue, "jobMap");
|
||||
Object job = jobMap.get(jobId);
|
||||
ReflectionTestUtils.setField(job, "queuedAt", Instant.now().minusSeconds(60));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("executeJob")
|
||||
class ExecuteJob {
|
||||
|
||||
@Test
|
||||
@DisplayName("a cancelled job is skipped by executeJob without running its work")
|
||||
@SuppressWarnings("unchecked")
|
||||
void cancelledJobSkipped() throws Exception {
|
||||
java.util.concurrent.atomic.AtomicBoolean ran =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob(
|
||||
"cancelled",
|
||||
50,
|
||||
() -> {
|
||||
ran.set(true);
|
||||
return "should-not-run";
|
||||
},
|
||||
1000);
|
||||
|
||||
// Grab the real QueuedJob instance, mark it cancelled, then drive executeJob directly.
|
||||
var jobMap =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(jobQueue, "jobMap");
|
||||
Object job = jobMap.get("cancelled");
|
||||
ReflectionTestUtils.setField(job, "cancelled", true);
|
||||
|
||||
var executeJob = JobQueue.class.getDeclaredMethod("executeJob", job.getClass());
|
||||
executeJob.setAccessible(true);
|
||||
executeJob.invoke(jobQueue, job);
|
||||
|
||||
// The early return means the work supplier never ran.
|
||||
assertThat(ran.get()).isFalse();
|
||||
assertThat(future.isDone()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-ResponseEntity result is wrapped in ResponseEntity.ok")
|
||||
void nonResponseEntityWrapped() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("wrap", 50, () -> "plain", 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
ResponseEntity<?> response = future.join();
|
||||
assertThat(response.getBody()).isEqualTo("plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ResponseEntity result is forwarded as-is")
|
||||
void responseEntityForwarded() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
ResponseEntity<String> inner = ResponseEntity.ok("inner");
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("forward", 50, () -> inner, 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future.join()).isSameAs(inner);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a failing job completes its future exceptionally")
|
||||
void failingJobCompletesExceptionally() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new RuntimeException("exec-boom");
|
||||
};
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("fail", 50, failing, 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future).isCompletedExceptionally();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("executeWithTimeout")
|
||||
class ExecuteWithTimeout {
|
||||
|
||||
@Test
|
||||
@DisplayName("with no timeout it joins and returns the value")
|
||||
void noTimeoutJoins() {
|
||||
Object result =
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", (Supplier<Object>) () -> "joined", 0L);
|
||||
assertThat(result).isEqualTo("joined");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an execution failure is unwrapped to its cause")
|
||||
void executionFailureUnwrapped() {
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new IllegalStateException("inner-cause");
|
||||
};
|
||||
Throwable thrown =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Throwable.class,
|
||||
() ->
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", failing, 1000L));
|
||||
assertThat(messageChain(thrown)).contains("inner-cause");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a slow job exceeds the timeout and throws TimeoutException")
|
||||
void slowJobTimesOut() {
|
||||
Supplier<Object> slow =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond 1ms
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
Throwable thrown =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Throwable.class,
|
||||
() ->
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", slow, 1L));
|
||||
assertThat(messageChain(thrown)).contains("timed out");
|
||||
}
|
||||
|
||||
// Spring's ReflectionTestUtils wraps checked exceptions, so inspect the whole cause chain.
|
||||
private String messageChain(Throwable t) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Throwable c = t; c != null; c = c.getCause()) {
|
||||
if (c.getMessage() != null) {
|
||||
sb.append(c.getMessage()).append('|');
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateQueueCapacity")
|
||||
class UpdateQueueCapacity {
|
||||
|
||||
@Test
|
||||
@DisplayName("resizes the queue and preserves queued jobs when capacity changes")
|
||||
void resizesQueue() {
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(10));
|
||||
jobQueue.queueJob("keep", 50, () -> "a", 1000);
|
||||
|
||||
// Force a new, smaller capacity on the next recalculation.
|
||||
when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(4);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(jobQueue, "updateQueueCapacity");
|
||||
|
||||
assertThat(jobQueue.getQueueCapacity()).isEqualTo(4);
|
||||
// The previously queued job survived the drain into the new queue.
|
||||
assertThat(jobQueue.getCurrentQueueSize()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getQueueStats")
|
||||
class QueueStats {
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the current resource status name")
|
||||
void includesResourceStatus() {
|
||||
statusRef.set(ResourceStatus.WARNING);
|
||||
var stats = jobQueue.getQueueStats();
|
||||
assertThat(stats.get("resourceStatus")).isEqualTo("WARNING");
|
||||
assertThat(stats).containsKeys("queuedJobs", "queueCapacity", "rejectedJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
|
||||
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
|
||||
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
|
||||
* isolated.
|
||||
*/
|
||||
class LoginAgreementServiceTest {
|
||||
|
||||
@TempDir Path customFilesDir;
|
||||
|
||||
private ApplicationProperties properties;
|
||||
private ApplicationProperties.Legal.LoginAgreement config;
|
||||
private LoginAgreementService service;
|
||||
private Path disclaimerDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new ApplicationProperties();
|
||||
config = properties.getLegal().getLoginAgreement();
|
||||
service = new LoginAgreementService(properties);
|
||||
disclaimerDir = customFilesDir.resolve("disclaimer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
|
||||
*/
|
||||
private void withMockedPath(Runnable action) {
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getCustomFilesPath)
|
||||
.thenReturn(customFilesDir.toString());
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(String locale, String content) throws IOException {
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsReflectConfig() {
|
||||
config.setEnabled(true);
|
||||
config.setShowInAnonymousMode(false);
|
||||
assertTrue(service.isEnabled());
|
||||
assertFalse(service.isShowInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsExactLocaleFile() throws IOException {
|
||||
writeFile("fr-FR", "# Avis");
|
||||
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToBaseLanguage() throws IOException {
|
||||
// Only a language-only file exists; a region-specific request should fall back to it.
|
||||
writeFile("de", "# Hinweis");
|
||||
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToDefaultLocale() throws IOException {
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
writeFile("en-GB", "# Notice");
|
||||
// No file for the requested locale -> falls through to the configured default locale.
|
||||
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToFallbackTextWhenNoFile() {
|
||||
config.setFallbackText("# Fallback");
|
||||
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsEmptyWhenNothingConfigured() {
|
||||
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
|
||||
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
|
||||
Files.writeString(
|
||||
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
|
||||
config.setFallbackText("safe");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertEquals("safe", service.resolveContent("../secret"));
|
||||
assertEquals("safe", service.resolveContent("..%2Fsecret"));
|
||||
assertEquals("safe", service.resolveContent("/etc/passwd"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertNull(service.readRawForLocale("../secret"));
|
||||
assertNull(service.readRawForLocale("en/GB"));
|
||||
assertNull(service.readRawForLocale("C:\\x"));
|
||||
assertNull(service.readRawForLocale(null));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawReturnsEmptyForValidButAbsentLocale() {
|
||||
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlongLocaleIsRejectedWithoutStackOverflow() {
|
||||
// Guards against the regex-recursion stack overflow on unbounded input.
|
||||
String hostile = "en" + "-ab".repeat(4000);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
|
||||
assertNull(service.readRawForLocale(hostile));
|
||||
assertDoesNotThrow(() -> service.resolveContent(hostile));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeThenReadRoundTrips() throws IOException {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
|
||||
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBlankDeletesFile() throws IOException {
|
||||
writeFile("fr-FR", "# Bonjour");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
|
||||
assertEquals("", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() ->
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.writeForLocale("../escape", "x")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
|
||||
writeFile("en-GB", "a");
|
||||
writeFile("fr-FR", "b");
|
||||
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
var locales = service.listLocalesWithContent();
|
||||
assertTrue(locales.contains("en-GB"));
|
||||
assertTrue(locales.contains("fr-FR"));
|
||||
assertEquals(2, locales.size());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedFileIsIgnored() throws IOException {
|
||||
// Files beyond the read cap are skipped rather than loaded into heap.
|
||||
byte[] big = new byte[300 * 1024];
|
||||
java.util.Arrays.fill(big, (byte) 'x');
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.write(disclaimerDir.resolve("en-GB.md"), big);
|
||||
config.setFallbackText("small-fallback");
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
|
||||
}
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
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.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
import stirling.software.common.service.MobileScannerService.SessionInfo;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link MobileScannerService}. The service stores uploaded files in a temp
|
||||
* directory. To keep tests isolated and deterministic, the {@code tempDirectory} field is
|
||||
* redirected to a JUnit {@link TempDir} via reflection after construction.
|
||||
*/
|
||||
class MobileScannerServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private MobileScannerService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
service = new MobileScannerService();
|
||||
// Redirect the service's temp directory to the isolated test temp dir.
|
||||
ReflectionTestUtils.setField(service, "tempDirectory", tempDir);
|
||||
}
|
||||
|
||||
private MultipartFile file(String name, String content) {
|
||||
return new MockMultipartFile(
|
||||
"file", name, "text/plain", content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private MultipartFile emptyFile(String name) {
|
||||
return new MockMultipartFile("file", name, "text/plain", new byte[0]);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSession")
|
||||
class CreateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates a session and returns coherent SessionInfo")
|
||||
void createsSession() {
|
||||
SessionInfo info = service.createSession("abc-123");
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals("abc-123", info.getSessionId());
|
||||
assertTrue(info.getCreatedAt() > 0);
|
||||
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
|
||||
assertEquals(info.getCreatedAt() + info.getTimeoutMs(), info.getExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("session is retrievable via validateSession after creation")
|
||||
void createdSessionIsValid() {
|
||||
service.createSession("sess1");
|
||||
assertNotNull(service.validateSession("sess1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null session ID")
|
||||
void rejectsNull() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects blank session ID")
|
||||
void rejectsBlank() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects session ID with invalid characters")
|
||||
void rejectsInvalidChars() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad/id"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad id"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts alphanumeric and hyphen session IDs")
|
||||
void acceptsValidChars() {
|
||||
assertNotNull(service.createSession("ABC-def-123"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("validateSession")
|
||||
class ValidateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for unknown session")
|
||||
void unknownReturnsNull() {
|
||||
assertNull(service.validateSession("does-not-exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns SessionInfo for an existing session")
|
||||
void existingReturnsInfo() {
|
||||
service.createSession("s1");
|
||||
SessionInfo info = service.validateSession("s1");
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals("s1", info.getSessionId());
|
||||
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("expires and removes a session whose last access is in the past")
|
||||
void expiredSessionRemoved() {
|
||||
service.createSession("expired");
|
||||
|
||||
// Force the underlying session's last access far into the past.
|
||||
forceLastAccess("expired", System.currentTimeMillis() - (20 * 60 * 1000L));
|
||||
|
||||
assertNull(service.validateSession("expired"));
|
||||
// After expiry the session should be gone entirely.
|
||||
assertNull(service.validateSession("expired"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("uploadFiles")
|
||||
class UploadFiles {
|
||||
|
||||
@Test
|
||||
@DisplayName("stores files and records metadata")
|
||||
void storesFiles() throws IOException {
|
||||
service.createSession("up1");
|
||||
service.uploadFiles("up1", List.of(file("scan.txt", "hello")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up1");
|
||||
assertEquals(1, metas.size());
|
||||
FileMetadata meta = metas.get(0);
|
||||
assertEquals("scan.txt", meta.getFilename());
|
||||
assertEquals(5, meta.getSize());
|
||||
assertEquals("text/plain", meta.getContentType());
|
||||
|
||||
// File physically exists on disk.
|
||||
Path stored = tempDir.resolve("up1").resolve("scan.txt");
|
||||
assertTrue(Files.exists(stored));
|
||||
assertEquals("hello", Files.readString(stored));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
|
||||
void autoCreatesSession() throws IOException {
|
||||
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("new-session");
|
||||
assertEquals(1, metas.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips empty files")
|
||||
void skipsEmptyFiles() throws IOException {
|
||||
service.createSession("up2");
|
||||
service.uploadFiles("up2", List.of(emptyFile("empty.txt"), file("real.txt", "x")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up2");
|
||||
assertEquals(1, metas.size());
|
||||
assertEquals("real.txt", metas.get(0).getFilename());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizes dangerous filename characters")
|
||||
void sanitizesFilename() throws IOException {
|
||||
service.createSession("up3");
|
||||
service.uploadFiles("up3", List.of(file("we ird@na#me.txt", "x")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up3");
|
||||
assertEquals(1, metas.size());
|
||||
String stored = metas.get(0).getFilename();
|
||||
// Disallowed chars replaced with underscores; allowed set is [a-zA-Z0-9._-].
|
||||
assertTrue(stored.matches("[a-zA-Z0-9._-]+"), "unexpected filename: " + stored);
|
||||
assertTrue(Files.exists(tempDir.resolve("up3").resolve(stored)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles duplicate filenames by appending a counter")
|
||||
void handlesDuplicateFilenames() throws IOException {
|
||||
service.createSession("up4");
|
||||
service.uploadFiles("up4", List.of(file("dup.txt", "one")));
|
||||
service.uploadFiles("up4", List.of(file("dup.txt", "two")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up4");
|
||||
assertEquals(2, metas.size());
|
||||
|
||||
Path original = tempDir.resolve("up4").resolve("dup.txt");
|
||||
Path renamed = tempDir.resolve("up4").resolve("dup-1.txt");
|
||||
assertTrue(Files.exists(original));
|
||||
assertTrue(Files.exists(renamed));
|
||||
assertEquals("one", Files.readString(original));
|
||||
assertEquals("two", Files.readString(renamed));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to a generated name when original filename is null")
|
||||
void generatesNameWhenNull() throws IOException {
|
||||
service.createSession("up5");
|
||||
MultipartFile noName =
|
||||
new MockMultipartFile("file", null, "text/plain", "x".getBytes());
|
||||
service.uploadFiles("up5", List.of(noName));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up5");
|
||||
assertEquals(1, metas.size());
|
||||
assertTrue(metas.get(0).getFilename().startsWith("upload-"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects invalid session ID before any storage")
|
||||
void rejectsInvalidSessionId() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.uploadFiles("bad/id", List.of(file("a.txt", "x"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uploading an empty list leaves no files")
|
||||
void emptyListNoFiles() throws IOException {
|
||||
service.createSession("up6");
|
||||
service.uploadFiles("up6", List.of());
|
||||
|
||||
assertTrue(service.getSessionFiles("up6").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSessionFiles")
|
||||
class GetSessionFiles {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list for unknown session")
|
||||
void unknownReturnsEmpty() {
|
||||
assertTrue(service.getSessionFiles("nope").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a defensive copy of the metadata list")
|
||||
void returnsDefensiveCopy() throws IOException {
|
||||
service.createSession("g1");
|
||||
service.uploadFiles("g1", List.of(file("a.txt", "x")));
|
||||
|
||||
List<FileMetadata> first = service.getSessionFiles("g1");
|
||||
first.clear();
|
||||
|
||||
// Mutating the returned list must not affect the service's internal state.
|
||||
assertEquals(1, service.getSessionFiles("g1").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getFile")
|
||||
class GetFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the path of an uploaded file")
|
||||
void returnsPath() throws IOException {
|
||||
service.createSession("f1");
|
||||
service.uploadFiles("f1", List.of(file("doc.txt", "body")));
|
||||
|
||||
Path path = service.getFile("f1", "doc.txt");
|
||||
assertTrue(Files.exists(path));
|
||||
assertEquals("body", Files.readString(path));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when the session does not exist")
|
||||
void unknownSessionThrows() {
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> service.getFile("ghost", "doc.txt"));
|
||||
assertTrue(ex.getMessage().contains("Session not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when the file does not exist in an existing session")
|
||||
void unknownFileThrows() throws IOException {
|
||||
service.createSession("f2");
|
||||
service.uploadFiles("f2", List.of(file("present.txt", "x")));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> service.getFile("f2", "missing.txt"));
|
||||
assertTrue(ex.getMessage().contains("File not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects filenames containing path separators")
|
||||
void rejectsPathSeparators() throws IOException {
|
||||
service.createSession("f3");
|
||||
service.uploadFiles("f3", List.of(file("ok.txt", "x")));
|
||||
|
||||
assertThrows(IOException.class, () -> service.getFile("f3", "../escape.txt"));
|
||||
assertThrows(IOException.class, () -> service.getFile("f3", "sub/file.txt"));
|
||||
assertThrows(IOException.class, () -> service.getFile("f3", "sub\\file.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects blank filename")
|
||||
void rejectsBlankFilename() throws IOException {
|
||||
service.createSession("f4");
|
||||
service.uploadFiles("f4", List.of(file("ok.txt", "x")));
|
||||
|
||||
assertThrows(IOException.class, () -> service.getFile("f4", " "));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteFileAfterDownload")
|
||||
class DeleteFileAfterDownload {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes a single file but keeps the session if others remain")
|
||||
void deletesOneFile() throws IOException {
|
||||
service.createSession("d1");
|
||||
service.uploadFiles("d1", List.of(file("a.txt", "x"), file("b.txt", "y")));
|
||||
|
||||
service.deleteFileAfterDownload("d1", "a.txt");
|
||||
|
||||
assertFalse(Files.exists(tempDir.resolve("d1").resolve("a.txt")));
|
||||
// Session still present because not all files have been downloaded.
|
||||
assertNotNull(service.validateSession("d1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes the entire session once all files are marked downloaded")
|
||||
void deletesSessionWhenAllDownloaded() throws IOException {
|
||||
service.createSession("d2");
|
||||
service.uploadFiles("d2", List.of(file("only.txt", "x")));
|
||||
|
||||
// Mark the file as downloaded via getFile, then delete it.
|
||||
service.getFile("d2", "only.txt");
|
||||
service.deleteFileAfterDownload("d2", "only.txt");
|
||||
|
||||
assertNull(service.validateSession("d2"));
|
||||
assertFalse(Files.exists(tempDir.resolve("d2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw for an unknown session")
|
||||
void unknownSessionNoThrow() {
|
||||
assertDoesNotThrow(() -> service.deleteFileAfterDownload("ghost", "a.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("swallows invalid filename input without throwing")
|
||||
void invalidFilenameNoThrow() throws IOException {
|
||||
service.createSession("d3");
|
||||
service.uploadFiles("d3", List.of(file("a.txt", "x")));
|
||||
|
||||
assertDoesNotThrow(() -> service.deleteFileAfterDownload("d3", "../escape.txt"));
|
||||
// Original file untouched.
|
||||
assertTrue(Files.exists(tempDir.resolve("d3").resolve("a.txt")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteSession")
|
||||
class DeleteSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("removes the session and all its files")
|
||||
void removesSessionAndFiles() throws IOException {
|
||||
service.createSession("x1");
|
||||
service.uploadFiles("x1", List.of(file("a.txt", "x"), file("b.txt", "y")));
|
||||
|
||||
assertTrue(Files.exists(tempDir.resolve("x1")));
|
||||
|
||||
service.deleteSession("x1");
|
||||
|
||||
assertNull(service.validateSession("x1"));
|
||||
assertFalse(Files.exists(tempDir.resolve("x1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("is a no-op for an unknown session")
|
||||
void unknownSessionNoOp() {
|
||||
assertDoesNotThrow(() -> service.deleteSession("never-existed"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupExpiredSessions")
|
||||
class CleanupExpiredSessions {
|
||||
|
||||
@Test
|
||||
@DisplayName("removes sessions past the timeout")
|
||||
void removesExpired() throws IOException {
|
||||
service.createSession("old");
|
||||
service.uploadFiles("old", List.of(file("a.txt", "x")));
|
||||
forceLastAccess("old", System.currentTimeMillis() - (20 * 60 * 1000L));
|
||||
|
||||
service.cleanupExpiredSessions();
|
||||
|
||||
assertNull(service.validateSession("old"));
|
||||
assertFalse(Files.exists(tempDir.resolve("old")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps sessions that are still fresh")
|
||||
void keepsFresh() {
|
||||
service.createSession("fresh");
|
||||
|
||||
service.cleanupExpiredSessions();
|
||||
|
||||
assertNotNull(service.validateSession("fresh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw when there are no sessions")
|
||||
void noSessionsNoThrow() {
|
||||
assertDoesNotThrow(() -> service.cleanupExpiredSessions());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SessionInfo accessors")
|
||||
class SessionInfoAccessors {
|
||||
|
||||
@Test
|
||||
@DisplayName("exposes all constructor values")
|
||||
void exposesValues() {
|
||||
SessionInfo info = new SessionInfo("id", 100L, 200L, 50L);
|
||||
assertEquals("id", info.getSessionId());
|
||||
assertEquals(100L, info.getCreatedAt());
|
||||
assertEquals(200L, info.getExpiresAt());
|
||||
assertEquals(50L, info.getTimeoutMs());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("FileMetadata accessors")
|
||||
class FileMetadataAccessors {
|
||||
|
||||
@Test
|
||||
@DisplayName("exposes all constructor values")
|
||||
void exposesValues() {
|
||||
FileMetadata meta = new FileMetadata("name.pdf", 1234L, "application/pdf");
|
||||
assertEquals("name.pdf", meta.getFilename());
|
||||
assertEquals(1234L, meta.getSize());
|
||||
assertEquals("application/pdf", meta.getContentType());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaches into the internal SessionData for a given session and forces its lastAccessTime, used
|
||||
* to deterministically simulate expiry without sleeping.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void forceLastAccess(String sessionId, long lastAccessTime) {
|
||||
java.util.Map<String, Object> sessions =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(service, "activeSessions");
|
||||
assertNotNull(sessions);
|
||||
Object sessionData = sessions.get(sessionId);
|
||||
assertNotNull(sessionData, "session not found: " + sessionId);
|
||||
ReflectionTestUtils.setField(sessionData, "lastAccessTime", lastAccessTime);
|
||||
}
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
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.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.CustomMetadata;
|
||||
import stirling.software.common.model.PdfMetadata;
|
||||
|
||||
class PdfMetadataServiceTest {
|
||||
|
||||
private static final String LABEL = "Stirling-PDF v1.0.0";
|
||||
|
||||
/**
|
||||
* Builds a service whose pro-features are disabled (real ApplicationProperties, all defaults).
|
||||
*/
|
||||
private PdfMetadataService nonProService(UserServiceInterface userService) {
|
||||
return new PdfMetadataService(new ApplicationProperties(), LABEL, false, userService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("toCalendar(ZonedDateTime)")
|
||||
class ToCalendarTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for null input")
|
||||
void nullReturnsNull() {
|
||||
assertNull(PdfMetadataService.toCalendar(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converts ZonedDateTime preserving the instant")
|
||||
void convertsInstant() {
|
||||
ZonedDateTime zdt = ZonedDateTime.of(2021, 6, 15, 10, 30, 45, 0, ZoneId.of("UTC"));
|
||||
Calendar cal = PdfMetadataService.toCalendar(zdt);
|
||||
|
||||
assertNotNull(cal);
|
||||
assertEquals(zdt.toInstant().toEpochMilli(), cal.getTimeInMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseToCalendar(String)")
|
||||
class ParseToCalendarTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for null input")
|
||||
void nullReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for empty / blank input")
|
||||
void blankReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar(""));
|
||||
assertNull(PdfMetadataService.parseToCalendar(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for unparsable input")
|
||||
void invalidReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar("not a date"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("2021-06-15"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("2021/13/40 99:99:99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses a valid 'yyyy/MM/dd HH:mm:ss' string")
|
||||
void parsesValidDate() {
|
||||
Calendar cal = PdfMetadataService.parseToCalendar("2021/06/15 10:30:45");
|
||||
assertNotNull(cal);
|
||||
|
||||
// Build the expected instant the same way the implementation does so the
|
||||
// assertion is independent of the JVM's default time zone.
|
||||
long expectedMillis =
|
||||
LocalDateTime.of(2021, 6, 15, 10, 30, 45)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedMillis, cal.getTimeInMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractMetadataFromPdf(PDDocument)")
|
||||
class ExtractMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns all-null fields for a fresh empty document")
|
||||
void emptyDocumentYieldsNulls() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfMetadata md = service.extractMetadataFromPdf(doc);
|
||||
|
||||
assertNotNull(md);
|
||||
assertNull(md.getAuthor());
|
||||
assertNull(md.getProducer());
|
||||
assertNull(md.getTitle());
|
||||
assertNull(md.getCreator());
|
||||
assertNull(md.getSubject());
|
||||
assertNull(md.getKeywords());
|
||||
assertNull(md.getCreationDate());
|
||||
assertNull(md.getModificationDate());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reads back string and date fields set on the document")
|
||||
void readsBackPopulatedFields() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
info.setAuthor("Alice");
|
||||
info.setProducer("ProducerX");
|
||||
info.setTitle("My Title");
|
||||
info.setCreator("CreatorY");
|
||||
info.setSubject("Subject Z");
|
||||
info.setKeywords("k1, k2");
|
||||
|
||||
Calendar creation = Calendar.getInstance();
|
||||
creation.setTimeInMillis(1_600_000_000_000L);
|
||||
Calendar modification = Calendar.getInstance();
|
||||
modification.setTimeInMillis(1_700_000_000_000L);
|
||||
info.setCreationDate(creation);
|
||||
info.setModificationDate(modification);
|
||||
|
||||
PdfMetadata md = service.extractMetadataFromPdf(doc);
|
||||
|
||||
assertEquals("Alice", md.getAuthor());
|
||||
assertEquals("ProducerX", md.getProducer());
|
||||
assertEquals("My Title", md.getTitle());
|
||||
assertEquals("CreatorY", md.getCreator());
|
||||
assertEquals("Subject Z", md.getSubject());
|
||||
assertEquals("k1, k2", md.getKeywords());
|
||||
|
||||
assertNotNull(md.getCreationDate());
|
||||
assertNotNull(md.getModificationDate());
|
||||
assertEquals(1_600_000_000_000L, md.getCreationDate().toInstant().toEpochMilli());
|
||||
assertEquals(
|
||||
1_700_000_000_000L, md.getModificationDate().toInstant().toEpochMilli());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMetadataToPdf / setDefaultMetadata (non-pro path)")
|
||||
class SetMetadataNonProTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("writes producer label, title, subject, keywords and author from metadata")
|
||||
void writesCommonMetadata() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
PdfMetadata md =
|
||||
PdfMetadata.builder()
|
||||
.author("Bob")
|
||||
.title("Doc Title")
|
||||
.subject("Doc Subject")
|
||||
.keywords("a, b, c")
|
||||
.creationDate(
|
||||
ZonedDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
|
||||
.modificationDate(
|
||||
ZonedDateTime.of(2021, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
|
||||
.build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
service.setMetadataToPdf(doc, md);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
assertEquals("Doc Title", info.getTitle());
|
||||
assertEquals("Doc Subject", info.getSubject());
|
||||
assertEquals("a, b, c", info.getKeywords());
|
||||
// Non-pro: author is taken verbatim from the metadata.
|
||||
assertEquals("Bob", info.getAuthor());
|
||||
assertNotNull(info.getModificationDate());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("existing creation date is left untouched when not newly created")
|
||||
void keepsExistingCreationDate() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
ZonedDateTime creation = ZonedDateTime.of(2019, 5, 20, 8, 15, 0, 0, ZoneId.of("UTC"));
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md);
|
||||
|
||||
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
|
||||
// creationDate is non-null and newlyCreated=false, so setNewDocumentMetadata
|
||||
// is skipped and no creation date is written.
|
||||
assertNull(creationCal);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sets a fresh creation date when metadata has none")
|
||||
void setsCreationDateWhenMissing() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md);
|
||||
|
||||
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
|
||||
assertNotNull(creationCal);
|
||||
// Non-pro path writes the Stirling label as the creator.
|
||||
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("newlyCreated=true forces a fresh creation date even if metadata has one")
|
||||
void newlyCreatedForcesCreationDate() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
ZonedDateTime creation = ZonedDateTime.of(2018, 3, 3, 3, 3, 3, 0, ZoneId.of("UTC"));
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
|
||||
assertNotNull(creationCal);
|
||||
// The supplied creation date must have been honoured (not "now").
|
||||
assertEquals(creation.toInstant().toEpochMilli(), creationCal.getTimeInMillis());
|
||||
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"setDefaultMetadata round-trips existing document info through the producer label")
|
||||
void setDefaultMetadataRewritesProducer() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
info.setTitle("Original Title");
|
||||
info.setAuthor("Original Author");
|
||||
info.setProducer("Some Other Producer");
|
||||
|
||||
service.setDefaultMetadata(doc);
|
||||
|
||||
// extract + re-apply keeps title/author but rewrites producer to the label.
|
||||
assertEquals("Original Title", info.getTitle());
|
||||
assertEquals("Original Author", info.getAuthor());
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null string fields in metadata are written through without error")
|
||||
void handlesNullStringFields() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
PdfMetadata md = PdfMetadata.builder().build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
assertNull(info.getTitle());
|
||||
assertNull(info.getSubject());
|
||||
assertNull(info.getKeywords());
|
||||
assertNull(info.getAuthor());
|
||||
// newlyCreated=true always stamps a creation date.
|
||||
assertNotNull(info.getCreationDate());
|
||||
assertNotNull(info.getModificationDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMetadataToPdf (pro path with custom metadata)")
|
||||
class SetMetadataProTests {
|
||||
|
||||
private ApplicationProperties propsWithCustomMetadata(
|
||||
boolean autoUpdate, String author, String creator) {
|
||||
ApplicationProperties props = mock(ApplicationProperties.class);
|
||||
Premium premium = mock(Premium.class);
|
||||
ProFeatures proFeatures = mock(ProFeatures.class);
|
||||
CustomMetadata customMetadata = mock(CustomMetadata.class);
|
||||
|
||||
lenient().when(props.getPremium()).thenReturn(premium);
|
||||
lenient().when(premium.getProFeatures()).thenReturn(proFeatures);
|
||||
lenient().when(proFeatures.getCustomMetadata()).thenReturn(customMetadata);
|
||||
lenient().when(customMetadata.isAutoUpdateMetadata()).thenReturn(autoUpdate);
|
||||
lenient().when(customMetadata.getAuthor()).thenReturn(author);
|
||||
lenient().when(customMetadata.getCreator()).thenReturn(creator);
|
||||
return props;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses custom author and creator when pro and auto-update enabled")
|
||||
void appliesCustomAuthorAndCreator() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
|
||||
|
||||
PdfMetadata md = PdfMetadata.builder().author("Ignored").title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals("Custom Author", info.getAuthor());
|
||||
assertEquals("Custom Creator", info.getCreator());
|
||||
// Producer is set to the label by both setNewDocumentMetadata and
|
||||
// setCommonMetadata.
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces 'username' token with the current user when userService present")
|
||||
void replacesUsernameToken() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Report by username", "Creator");
|
||||
UserServiceInterface userService = mock(UserServiceInterface.class);
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
assertEquals("Report by alice", doc.getDocumentInformation().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves 'username' token intact when current user is null")
|
||||
void keepsTokenWhenUsernameNull() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Report by username", "Creator");
|
||||
UserServiceInterface userService = mock(UserServiceInterface.class);
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
assertEquals("Report by username", doc.getDocumentInformation().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom author applied even without a userService")
|
||||
void appliesCustomAuthorWithoutUserService() throws Exception {
|
||||
ApplicationProperties props = propsWithCustomMetadata(true, "Static Author", "Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
assertEquals("Static Author", doc.getDocumentInformation().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pro flag without auto-update keeps metadata author and label creator")
|
||||
void proButAutoUpdateDisabledUsesMetadata() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(false, "Custom Author", "Custom Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
|
||||
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals("Metadata Author", info.getAuthor());
|
||||
assertEquals(LABEL, info.getCreator());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto-update enabled but not pro keeps metadata author and label creator")
|
||||
void autoUpdateButNotProUsesMetadata() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, false, null);
|
||||
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals("Metadata Author", info.getAuthor());
|
||||
assertEquals(LABEL, info.getCreator());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import com.posthog.java.PostHog;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PostHogServiceTest {
|
||||
|
||||
private static final String UUID = "test-uuid-1234";
|
||||
private static final String APP_VERSION = "9.9.9";
|
||||
|
||||
@Mock PostHog postHog;
|
||||
@Mock UserServiceInterface userService;
|
||||
|
||||
/** Build an ApplicationProperties with analytics/posthog toggled. */
|
||||
private ApplicationProperties props(boolean analyticsEnabled) {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSystem().setEnableAnalytics(analyticsEnabled);
|
||||
return appProps;
|
||||
}
|
||||
|
||||
/** Construct the service under test. */
|
||||
private PostHogService newService(
|
||||
ApplicationProperties appProps,
|
||||
UserServiceInterface user,
|
||||
boolean configDirMounted,
|
||||
MockEnvironment env) {
|
||||
return new PostHogService(
|
||||
postHog, UUID, configDirMounted, APP_VERSION, appProps, user, env);
|
||||
}
|
||||
|
||||
private MockEnvironment env() {
|
||||
return new MockEnvironment();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Constructor / captureSystemInfo")
|
||||
class ConstructorBehavior {
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor captures system_info when posthog is enabled")
|
||||
void constructorCapturesWhenEnabled() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
|
||||
newService(appProps, userService, false, env());
|
||||
|
||||
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor does not capture when analytics disabled")
|
||||
void constructorNoCaptureWhenDisabled() {
|
||||
ApplicationProperties appProps = props(false);
|
||||
|
||||
newService(appProps, userService, false, env());
|
||||
|
||||
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor does not capture when posthog explicitly disabled")
|
||||
void constructorNoCaptureWhenPosthogOff() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
appProps.getSystem().setEnablePosthog(false);
|
||||
|
||||
newService(appProps, userService, false, env());
|
||||
|
||||
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor swallows exceptions thrown by postHog.capture")
|
||||
void constructorSwallowsCaptureException() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
doThrow(new RuntimeException("boom"))
|
||||
.when(postHog)
|
||||
.capture(anyString(), anyString(), anyMap());
|
||||
|
||||
// Must not propagate; constructor wraps capture in try/catch.
|
||||
assertDoesNotThrow(() -> newService(appProps, userService, false, env()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor works with null userService (optional dependency)")
|
||||
void constructorWithNullUserService() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
|
||||
assertDoesNotThrow(() -> newService(appProps, null, false, env()));
|
||||
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureEvent")
|
||||
class CaptureEvent {
|
||||
|
||||
@Test
|
||||
@DisplayName("captureEvent forwards to postHog when enabled and injects app_version")
|
||||
void captureEventWhenEnabled() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
// Reset the constructor's capture so we only assert on captureEvent.
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
properties.put("foo", "bar");
|
||||
service.captureEvent("my_event", properties);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHog).capture(eq(UUID), eq("my_event"), captor.capture());
|
||||
Map<String, Object> sent = captor.getValue();
|
||||
assertEquals("bar", sent.get("foo"));
|
||||
assertEquals(APP_VERSION, sent.get("app_version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captureEvent is a no-op when analytics disabled")
|
||||
void captureEventWhenDisabled() {
|
||||
ApplicationProperties appProps = props(false);
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
service.captureEvent("my_event", properties);
|
||||
|
||||
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
|
||||
// app_version must not be added when disabled (early return).
|
||||
assertFalse(properties.containsKey("app_version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captureEvent adds app_version key to the provided map")
|
||||
void captureEventMutatesMap() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
service.captureEvent("evt", properties);
|
||||
|
||||
assertTrue(properties.containsKey("app_version"));
|
||||
assertEquals(APP_VERSION, properties.get("app_version"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureServerMetrics")
|
||||
class CaptureServerMetrics {
|
||||
|
||||
private PostHogService disabledService() {
|
||||
// Keep posthog disabled so the constructor performs no capture; metrics
|
||||
// methods are independent of the enabled flag.
|
||||
return newService(props(false), userService, true, env());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes core application and system metrics")
|
||||
void includesCoreMetrics() {
|
||||
PostHogService service = disabledService();
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals(APP_VERSION, metrics.get("app_version"));
|
||||
assertEquals(true, metrics.get("mounted_config_dir"));
|
||||
assertNotNull(metrics.get("os_name"));
|
||||
assertNotNull(metrics.get("java_version"));
|
||||
assertTrue(metrics.containsKey("cpu_cores"));
|
||||
assertTrue(metrics.containsKey("total_memory"));
|
||||
assertTrue(metrics.containsKey("free_memory"));
|
||||
assertTrue(metrics.containsKey("process_id"));
|
||||
assertTrue(metrics.containsKey("jvm_uptime_ms"));
|
||||
assertTrue(metrics.containsKey("thread_count"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deployment_type defaults to JAR when not docker/exe")
|
||||
void deploymentTypeJar() {
|
||||
PostHogService service = disabledService();
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
// In the unit-test environment there is no /.dockerenv and no BROWSER_OPEN.
|
||||
assertEquals("JAR", metrics.get("deployment_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deployment_type becomes EXE when BROWSER_OPEN=true")
|
||||
void deploymentTypeExe() {
|
||||
MockEnvironment environment = env();
|
||||
environment.setProperty("BROWSER_OPEN", "true");
|
||||
PostHogService service = newService(props(false), userService, false, environment);
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals("EXE", metrics.get("deployment_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BROWSER_OPEN matching is case-insensitive")
|
||||
void deploymentTypeExeCaseInsensitive() {
|
||||
MockEnvironment environment = env();
|
||||
environment.setProperty("BROWSER_OPEN", "TRUE");
|
||||
PostHogService service = newService(props(false), userService, false, environment);
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals("EXE", metrics.get("deployment_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mounted_config_dir reflects the configDirMounted flag")
|
||||
void mountedConfigDirFalse() {
|
||||
PostHogService service = newService(props(false), userService, false, env());
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals(false, metrics.get("mounted_config_dir"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes total_users_created when userService present")
|
||||
void includesUserCountWhenUserServicePresent() {
|
||||
when(userService.getTotalUsersCount()).thenReturn(42L);
|
||||
PostHogService service = newService(props(false), userService, false, env());
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals(42L, metrics.get("total_users_created"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits total_users_created when userService is null")
|
||||
void omitsUserCountWhenUserServiceNull() {
|
||||
PostHogService service = newService(props(false), null, false, env());
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertFalse(metrics.containsKey("total_users_created"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("always embeds nested application_properties map")
|
||||
void embedsApplicationProperties() {
|
||||
PostHogService service = disabledService();
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertTrue(metrics.get("application_properties") instanceof Map);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureApplicationProperties")
|
||||
class CaptureApplicationProperties {
|
||||
|
||||
private PostHogService serviceWith(ApplicationProperties appProps) {
|
||||
// Disable analytics to keep the constructor from capturing.
|
||||
appProps.getSystem().setEnableAnalytics(false);
|
||||
return newService(appProps, userService, false, env());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes blank-trimmed legal strings only when non-empty")
|
||||
void legalPropertiesFiltered() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getLegal().setTermsAndConditions(" https://terms ");
|
||||
appProps.getLegal().setPrivacyPolicy(""); // blank -> skipped
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
// String values are trimmed by addIfNotEmpty.
|
||||
assertEquals("https://terms", p.get("legal_termsAndConditions"));
|
||||
assertFalse(p.containsKey("legal_privacyPolicy"));
|
||||
assertFalse(p.containsKey("legal_accessibilityStatement"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("always reports csrfDisabled true and login booleans")
|
||||
void securityProperties() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSecurity().setEnableLogin(true);
|
||||
appProps.getSecurity().setLoginAttemptCount(5);
|
||||
appProps.getSecurity().setLoginResetTimeMinutes(10);
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("security_csrfDisabled"));
|
||||
assertEquals(true, p.get("security_enableLogin"));
|
||||
assertEquals(5, p.get("security_loginAttemptCount"));
|
||||
assertEquals(10L, p.get("security_loginResetTimeMinutes"));
|
||||
assertEquals("all", p.get("security_loginMethod"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("oauth2 nested fields are omitted when oauth2 disabled")
|
||||
void oauth2DisabledOmitsNested() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
// oauth2.enabled defaults to false.
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(false, p.get("security_oauth2_enabled"));
|
||||
assertFalse(p.containsKey("security_oauth2_autoCreateUser"));
|
||||
assertFalse(p.containsKey("security_oauth2_provider"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("oauth2 nested fields are included when oauth2 enabled")
|
||||
void oauth2EnabledIncludesNested() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSecurity().getOauth2().setEnabled(true);
|
||||
appProps.getSecurity().getOauth2().setAutoCreateUser(true);
|
||||
appProps.getSecurity().getOauth2().setBlockRegistration(false);
|
||||
appProps.getSecurity().getOauth2().setUseAsUsername("email");
|
||||
appProps.getSecurity().getOauth2().setProvider("google");
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("security_oauth2_enabled"));
|
||||
assertEquals(true, p.get("security_oauth2_autoCreateUser"));
|
||||
assertEquals(false, p.get("security_oauth2_blockRegistration"));
|
||||
assertEquals("email", p.get("security_oauth2_useAsUsername"));
|
||||
assertEquals("google", p.get("security_oauth2_provider"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system analytics/posthog/scarf booleans are reported")
|
||||
void systemAnalyticsBooleans() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSystem().setEnableAnalytics(true);
|
||||
appProps.getSystem().setEnablePosthog(true);
|
||||
appProps.getSystem().setEnableScarf(false);
|
||||
appProps.getSystem().setDefaultLocale("en-US");
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
// Constructor will capture once because analytics is enabled; that's fine.
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals("en-US", p.get("system_defaultLocale"));
|
||||
assertEquals(true, p.get("system_enableAnalytics"));
|
||||
assertEquals(true, p.get("system_enablePosthog"));
|
||||
// isScarfEnabled() is false because enableScarf is false.
|
||||
assertEquals(false, p.get("system_enableScarf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metrics_enabled and autoPipeline output folder included appropriately")
|
||||
void metricsAndAutoPipeline() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getMetrics().setEnabled(true);
|
||||
appProps.getAutoPipeline().setOutputFolder("/tmp/out");
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("metrics_enabled"));
|
||||
assertEquals("/tmp/out", p.get("autoPipeline_outputFolder"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enterprise metadata flag omitted when premium disabled")
|
||||
void premiumDisabledOmitsMetadata() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
// premium.enabled defaults to false.
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(false, p.get("enterpriseEdition_enabled"));
|
||||
assertFalse(p.containsKey("enterpriseEdition_customMetadata_autoUpdateMetadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enterprise metadata flag included when premium enabled")
|
||||
void premiumEnabledIncludesMetadata() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getPremium().setEnabled(true);
|
||||
appProps.getPremium().getProFeatures().getCustomMetadata().setAutoUpdateMetadata(true);
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("enterpriseEdition_enabled"));
|
||||
assertEquals(true, p.get("enterpriseEdition_customMetadata_autoUpdateMetadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ui appNameNavbar omitted when blank, included when set")
|
||||
void uiAppNameNavbar() {
|
||||
ApplicationProperties blankProps = new ApplicationProperties();
|
||||
// appNameNavbar getter returns null for blank/empty values.
|
||||
PostHogService blankService = serviceWith(blankProps);
|
||||
Map<String, Object> blank = blankService.captureApplicationProperties();
|
||||
assertFalse(blank.containsKey("ui_appNameNavbar"));
|
||||
|
||||
ApplicationProperties namedProps = new ApplicationProperties();
|
||||
namedProps.getUi().setAppNameNavbar("My App");
|
||||
PostHogService namedService = serviceWith(namedProps);
|
||||
Map<String, Object> named = namedService.captureApplicationProperties();
|
||||
assertEquals("My App", named.get("ui_appNameNavbar"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a non-null map for a fresh ApplicationProperties")
|
||||
void defaultsProduceNonNullMap() {
|
||||
PostHogService service = serviceWith(new ApplicationProperties());
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertNotNull(p);
|
||||
// csrfDisabled is always added regardless of config, so map is never empty.
|
||||
assertTrue(p.containsKey("security_csrfDisabled"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.MemoryUsage;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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 org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
/** Additional coverage for ResourceMonitor branches not exercised by ResourceMonitorTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResourceMonitorMoreTest {
|
||||
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock private OperatingSystemMXBean osMXBean;
|
||||
@Mock private MemoryMXBean memoryMXBean;
|
||||
@Mock private MemoryUsage heapUsage;
|
||||
@Mock private MemoryUsage nonHeapUsage;
|
||||
|
||||
private final AtomicReference<ResourceStatus> currentStatus =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
private final AtomicReference<ResourceMetrics> latestMetrics =
|
||||
new AtomicReference<>(new ResourceMetrics());
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
resourceMonitor = new ResourceMonitor();
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
|
||||
}
|
||||
|
||||
private void stubMemory(long heapUsed, long nonHeapUsed) {
|
||||
lenient().when(heapUsage.getUsed()).thenReturn(heapUsed);
|
||||
lenient().when(nonHeapUsage.getUsed()).thenReturn(nonHeapUsed);
|
||||
lenient().when(memoryMXBean.getHeapMemoryUsage()).thenReturn(heapUsage);
|
||||
lenient().when(memoryMXBean.getNonHeapMemoryUsage()).thenReturn(nonHeapUsage);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateResourceMetrics status transitions")
|
||||
class UpdateMetrics {
|
||||
|
||||
@Test
|
||||
@DisplayName("high CPU load drives the status to CRITICAL")
|
||||
void criticalOnHighCpu() {
|
||||
// load average / processors = 4 / 2 = 2.0 -> well over critical threshold.
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(4.0);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.CRITICAL);
|
||||
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(2.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moderately high CPU load drives the status to WARNING")
|
||||
void warningOnModerateCpu() {
|
||||
// 1.6 / 2 = 0.8 -> above high (0.75) but below critical (0.9).
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(1.6);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("low load keeps the status at OK")
|
||||
void okOnLowLoad() {
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(0.2);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(4);
|
||||
stubMemory(1L, 1L);
|
||||
currentStatus.set(ResourceStatus.WARNING); // ensure a transition log path is hit
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a negative load average triggers the alternative CPU fallback")
|
||||
void negativeLoadUsesFallback() {
|
||||
// getSystemLoadAverage returns -1 on platforms (e.g. Windows) where it is unsupported.
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(-1.0);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(4);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
// The mock OS bean has no getProcessCpuLoad/getSystemCpuLoad, so fallback yields 0.5.
|
||||
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(0.5);
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exception while sampling is swallowed and status is unchanged")
|
||||
void samplingExceptionSwallowed() {
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(0.1);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
when(memoryMXBean.getHeapMemoryUsage())
|
||||
.thenThrow(new RuntimeException("jmx unavailable"));
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
|
||||
// Must not propagate; the catch in updateResourceMetrics handles it.
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAlternativeCpuLoad")
|
||||
class AlternativeCpuLoad {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses getProcessCpuLoad via reflection when present")
|
||||
void usesProcessCpuLoad() {
|
||||
// A bean exposing getProcessCpuLoad lets the reflective fallback return its value.
|
||||
OperatingSystemMXBean withCpuLoad = new OsBeanWithProcessCpuLoad(0.42);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", withCpuLoad);
|
||||
|
||||
double load =
|
||||
(double)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
resourceMonitor, "getAlternativeCpuLoad");
|
||||
assertThat(load).isEqualTo(0.42);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults to 0.5 when no CPU-load method is available")
|
||||
void defaultsWhenUnavailable() {
|
||||
double load =
|
||||
(double)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
resourceMonitor, "getAlternativeCpuLoad");
|
||||
assertThat(load).isEqualTo(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateDynamicQueueCapacity memory pressure")
|
||||
class MemoryPressure {
|
||||
|
||||
@Test
|
||||
@DisplayName("high memory usage halves the computed capacity")
|
||||
void highMemoryHalvesCapacity() {
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
// memoryUsage > 0.8 triggers the additional 0.5 multiplier.
|
||||
latestMetrics.set(new ResourceMetrics(0.1, 0.85, 1, 1, 1, Instant.now()));
|
||||
|
||||
int capacity = resourceMonitor.calculateDynamicQueueCapacity(10, 2);
|
||||
// OK factor 1.0 * 0.5 = 0.5; ceil(10 * 0.5) = 5.
|
||||
assertThat(capacity).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ResourceMetrics")
|
||||
class Metrics {
|
||||
|
||||
@Test
|
||||
@DisplayName("getAge returns a non-negative duration")
|
||||
void getAgeNonNegative() {
|
||||
ResourceMetrics m = new ResourceMetrics(0, 0, 0, 0, 0, Instant.now().minusSeconds(1));
|
||||
assertThat(m.getAge().toMillis()).isGreaterThanOrEqualTo(1000L);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("lifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize schedules sampling and shutdown stops the scheduler")
|
||||
void initializeAndShutdown() {
|
||||
// Real bean so initialize() schedules against a live virtual-thread scheduler.
|
||||
ResourceMonitor live = new ResourceMonitor();
|
||||
ReflectionTestUtils.setField(live, "monitorIntervalMs", 60000L);
|
||||
live.initialize();
|
||||
|
||||
ScheduledExecutorService scheduler =
|
||||
(ScheduledExecutorService) ReflectionTestUtils.getField(live, "scheduler");
|
||||
assertThat(scheduler.isShutdown()).isFalse();
|
||||
|
||||
live.shutdown();
|
||||
assertThat(scheduler.isShutdown()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal OS bean stub exposing getProcessCpuLoad so the reflective fallback can find it. */
|
||||
private static class OsBeanWithProcessCpuLoad implements OperatingSystemMXBean {
|
||||
private final double cpuLoad;
|
||||
|
||||
OsBeanWithProcessCpuLoad(double cpuLoad) {
|
||||
this.cpuLoad = cpuLoad;
|
||||
}
|
||||
|
||||
// Reflectively located by getAlternativeCpuLoad.
|
||||
public double getProcessCpuLoad() {
|
||||
return cpuLoad;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getArch() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVersion() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAvailableProcessors() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getSystemLoadAverage() {
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public javax.management.ObjectName getObjectName() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Html.UrlSecurity;
|
||||
import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel;
|
||||
|
||||
class SsrfProtectionServiceTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private UrlSecurity config;
|
||||
private SsrfProtectionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
// Real config object: drill down to the live UrlSecurity instance and mutate it.
|
||||
config = applicationProperties.getSystem().getHtml().getUrlSecurity();
|
||||
service = new SsrfProtectionService(applicationProperties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Protection disabled / always-allowed inputs")
|
||||
class AlwaysAllowed {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true for any URL when protection disabled")
|
||||
void disabledAllowsEverything() {
|
||||
config.setEnabled(false);
|
||||
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("not a url")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\t"})
|
||||
@DisplayName("returns false for null/blank when enabled")
|
||||
void blankRejected(String url) {
|
||||
config.setEnabled(true);
|
||||
assertThat(service.isUrlAllowed(url)).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"data:text/plain;base64,SGVsbG8=",
|
||||
"DATA:image/png;base64,iVBOR",
|
||||
"#section",
|
||||
"#"
|
||||
})
|
||||
@DisplayName("data: URLs and fragments are always allowed")
|
||||
void dataAndFragmentAllowed(String url) {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MAX);
|
||||
assertThat(service.isUrlAllowed(url)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("OFF level")
|
||||
class OffLevel {
|
||||
|
||||
@Test
|
||||
@DisplayName("allows external and internal hosts alike")
|
||||
void offAllowsAll() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.OFF);
|
||||
assertThat(service.isUrlAllowed("http://10.0.0.1/secret")).isTrue();
|
||||
assertThat(service.isUrlAllowed("https://example.com")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MAX level - allowlist only")
|
||||
class MaxLevel {
|
||||
|
||||
@BeforeEach
|
||||
void max() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MAX);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows only whitelisted hosts (case-insensitive)")
|
||||
void allowsWhitelistedHost() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("https://EXAMPLE.com/path")).isTrue();
|
||||
assertThat(service.isUrlAllowed("https://other.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks when allowlist is empty")
|
||||
void emptyAllowlistBlocks() {
|
||||
assertThat(service.isUrlAllowed("https://example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks URL with no host")
|
||||
void noHostBlocked() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("file:///etc/passwd")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks malformed URL (parse exception path)")
|
||||
void malformedBlocked() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("http://exa mple.com")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - host parsing and lists")
|
||||
class MediumHostAndLists {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows a normal public literal IP")
|
||||
void allowsPublicIp() {
|
||||
assertThat(service.isUrlAllowed("http://93.184.216.34/page")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks URL with no host")
|
||||
void noHostBlocked() {
|
||||
assertThat(service.isUrlAllowed("mailto:test@example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks malformed URL (parse exception path)")
|
||||
void malformedBlocked() {
|
||||
assertThat(service.isUrlAllowed("ht!tp://%%%")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks explicitly blocked domain (case-insensitive)")
|
||||
void blockedDomain() {
|
||||
config.setBlockedDomains(List.of("evil.com"));
|
||||
assertThat(service.isUrlAllowed("http://EVIL.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks internal TLD suffixes")
|
||||
void internalTld() {
|
||||
// default internalTlds include .local, .internal, .corp, .home
|
||||
assertThat(service.isUrlAllowed("http://server.local")).isFalse();
|
||||
assertThat(service.isUrlAllowed("http://host.internal")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allowlist present: host not in list is blocked before any DNS lookup")
|
||||
void allowlistRejectsUnlisted() {
|
||||
// notexample.com is rejected by the allowlist check, which runs before DNS resolution,
|
||||
// so this stays deterministic offline.
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("http://notexample.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allowlist present: exact host and subdomain pass the allowlist gate")
|
||||
void allowlistAcceptsExactAndSubdomain() {
|
||||
// Allow a literal IP so the subsequent DNS resolution is the identity and network
|
||||
// checks are disabled, keeping the allow path deterministic without external DNS.
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLocalhost(false);
|
||||
config.setBlockLinkLocal(false);
|
||||
config.setBlockCloudMetadata(false);
|
||||
config.setAllowedDomains(List.of("93.184.216.34"));
|
||||
assertThat(service.isUrlAllowed("http://93.184.216.34")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - network based blocking via literal IPs")
|
||||
class MediumNetworkBlocking {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks loopback when blockLocalhost enabled")
|
||||
void blocksLoopback() {
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1/admin")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows loopback when blockLocalhost disabled and private/link checks off")
|
||||
void allowsLoopbackWhenAllChecksOff() {
|
||||
config.setBlockLocalhost(false);
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLinkLocal(false);
|
||||
config.setBlockCloudMetadata(false);
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1/ok")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"http://10.1.2.3",
|
||||
"http://192.168.0.5",
|
||||
"http://172.16.0.9",
|
||||
"http://172.31.255.1",
|
||||
"http://100.64.0.1"
|
||||
})
|
||||
@DisplayName("blocks RFC1918 / CGNAT private ranges")
|
||||
void blocksPrivateRanges(String url) {
|
||||
assertThat(service.isUrlAllowed(url)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("172.x and 100.x outside private sub-range are not private")
|
||||
void boundaryRangesNotPrivate() {
|
||||
// 172.15/172.32 outside 16-31; 100.63/100.128 outside 64-127.
|
||||
assertThat(service.isUrlAllowed("http://172.15.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://172.32.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://100.63.0.1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows private range when blockPrivateNetworks disabled")
|
||||
void allowsPrivateWhenDisabled() {
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLocalhost(false);
|
||||
assertThat(service.isUrlAllowed("http://10.1.2.3")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks link-local 169.254.x via private-network check")
|
||||
void blocksLinkLocal() {
|
||||
assertThat(service.isUrlAllowed("http://169.254.1.1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks AWS cloud-metadata IP 169.254.169.254")
|
||||
void blocksCloudMetadata() {
|
||||
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data/")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks unspecified address 0.0.0.0")
|
||||
void blocksUnspecified() {
|
||||
assertThat(service.isUrlAllowed("http://0.0.0.0")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks unresolvable host (UnknownHostException path)")
|
||||
void blocksUnresolvableHost() {
|
||||
assertThat(service.isUrlAllowed("http://nonexistent-host-stirling-test.invalid/page"))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - IPv6 literal handling")
|
||||
class MediumIpv6 {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 loopback ::1")
|
||||
void blocksIpv6Loopback() {
|
||||
assertThat(service.isUrlAllowed("http://[::1]/path")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 unique-local fc00::/7")
|
||||
void blocksIpv6UniqueLocal() {
|
||||
assertThat(service.isUrlAllowed("http://[fc00::1]")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 link-local fe80::/10")
|
||||
void blocksIpv6LinkLocal() {
|
||||
assertThat(service.isUrlAllowed("http://[fe80::1]")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv4-mapped IPv6 of a private address")
|
||||
void blocksIpv4MappedPrivate() {
|
||||
assertThat(service.isUrlAllowed("http://[::ffff:10.0.0.1]")).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/** Additional coverage for TaskManager branches not exercised by TaskManagerTest. */
|
||||
class TaskManagerMoreTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobStore jobStore;
|
||||
@Mock private ClusterBackplane clusterBackplane;
|
||||
|
||||
@InjectMocks private TaskManager taskManager;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
|
||||
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
private static byte[] buildZip(String... entryNames) throws Exception {
|
||||
var baos = new java.io.ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (String name : entryNames) {
|
||||
zos.putNextEntry(new ZipEntry(name));
|
||||
zos.write(("content-of-" + name).getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setFileResult ZIP handling")
|
||||
class ZipHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts a ZIP into individual file results and deletes the original")
|
||||
void extractsZipIntoIndividualFiles() throws Exception {
|
||||
String jobId = "zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
byte[] zipBytes = buildZip("a.pdf", "b.txt");
|
||||
when(fileStorage.retrieveInputStream("zip-file-id"))
|
||||
.thenReturn(new ByteArrayInputStream(zipBytes));
|
||||
// Each extracted entry is stored, returning a distinct StoredFile.
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenReturn(new FileStorage.StoredFile("extracted-a", 11L))
|
||||
.thenReturn(new FileStorage.StoredFile("extracted-b", 22L));
|
||||
when(fileStorage.deleteFile("zip-file-id")).thenReturn(true);
|
||||
|
||||
taskManager.setFileResult(jobId, "zip-file-id", "bundle.zip", "application/zip");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.isComplete()).isTrue();
|
||||
assertThat(result.hasMultipleFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles()).hasSize(2);
|
||||
// Content type is derived from the entry extension, not the ZIP content type.
|
||||
assertThat(result.getAllResultFiles().get(0).getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_PDF_VALUE);
|
||||
assertThat(result.getAllResultFiles().get(1).getContentType())
|
||||
.isEqualTo(MediaType.TEXT_PLAIN_VALUE);
|
||||
verify(fileStorage).deleteFile("zip-file-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty ZIP falls back to a single-file result")
|
||||
void emptyZipFallsBackToSingleFile() throws Exception {
|
||||
String jobId = "empty-zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
byte[] emptyZip = buildZip();
|
||||
when(fileStorage.retrieveInputStream("empty-zip-id"))
|
||||
.thenReturn(new ByteArrayInputStream(emptyZip));
|
||||
when(fileStorage.getFileSize("empty-zip-id")).thenReturn(7L);
|
||||
|
||||
taskManager.setFileResult(jobId, "empty-zip-id", "empty.zip", "application/zip");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasMultipleFiles()).isFalse();
|
||||
assertThat(result.getAllResultFiles()).hasSize(1);
|
||||
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("empty-zip-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZIP extraction failure falls back to a single-file result")
|
||||
void zipExtractionFailureFallsBackToSingleFile() throws Exception {
|
||||
String jobId = "bad-zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// retrieveInputStream throws so extractZipToIndividualFiles fails and we fall back.
|
||||
when(fileStorage.retrieveInputStream("bad-zip-id"))
|
||||
.thenThrow(new java.io.IOException("boom"));
|
||||
when(fileStorage.getFileSize("bad-zip-id")).thenReturn(99L);
|
||||
|
||||
taskManager.setFileResult(
|
||||
jobId, "bad-zip-id", "broken.zip", "application/x-zip-compressed");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("bad-zip-id");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setFileResult size fallback")
|
||||
class SizeFallback {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses size 0 when getFileSize throws for a non-zip file")
|
||||
void usesZeroSizeWhenGetFileSizeThrows() throws Exception {
|
||||
String jobId = "size-fail-job";
|
||||
taskManager.createTask(jobId);
|
||||
when(fileStorage.getFileSize("file-x")).thenThrow(new java.io.IOException("no stat"));
|
||||
|
||||
taskManager.setFileResult(jobId, "file-x", "doc.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.getAllResultFiles().get(0).getFileSize()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMultipleFileResults")
|
||||
class MultipleFileResults {
|
||||
|
||||
@Test
|
||||
@DisplayName("stores the provided list directly")
|
||||
void storesProvidedList() {
|
||||
String jobId = "multi-job";
|
||||
taskManager.createTask(jobId);
|
||||
List<ResultFile> files =
|
||||
List.of(
|
||||
ResultFile.builder().fileId("f1").fileName("1.pdf").build(),
|
||||
ResultFile.builder().fileId("f2").fileName("2.pdf").build());
|
||||
|
||||
taskManager.setMultipleFileResults(jobId, files);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasMultipleFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles()).hasSize(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getJobStats edge cases")
|
||||
class StatsEdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty manager reports zero average processing time")
|
||||
void emptyManagerZeroAverage() {
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
assertThat(stats.getTotalJobs()).isZero();
|
||||
assertThat(stats.getAverageProcessingTimeMs()).isZero();
|
||||
assertThat(stats.getOldestActiveJobTime()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accumulates processing time across multiple completed jobs")
|
||||
void accumulatesProcessingTime() {
|
||||
taskManager.createTask("c1");
|
||||
taskManager.setResult("c1", "r1");
|
||||
taskManager.createTask("c2");
|
||||
taskManager.setResult("c2", "r2");
|
||||
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
assertThat(stats.getCompletedJobs()).isEqualTo(2);
|
||||
assertThat(stats.getSuccessfulJobs()).isEqualTo(2);
|
||||
assertThat(stats.getAverageProcessingTimeMs()).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findResultFileByFileId")
|
||||
class FindResultFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns matching ResultFile metadata")
|
||||
void returnsMatch() throws Exception {
|
||||
taskManager.createTask("rf-job");
|
||||
when(fileStorage.getFileSize("target")).thenReturn(5L);
|
||||
taskManager.setFileResult("rf-job", "target", "t.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
ResultFile found = taskManager.findResultFileByFileId("target");
|
||||
assertThat(found).isNotNull();
|
||||
assertThat(found.getFileId()).isEqualTo("target");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when no job owns the file id")
|
||||
void returnsNullWhenAbsent() {
|
||||
assertThat(taskManager.findResultFileByFileId("nope")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findJobKeyByFileId")
|
||||
class FindJobKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the local job key when a job owns the file id")
|
||||
void returnsLocalKey() throws Exception {
|
||||
taskManager.createTask("owner-job");
|
||||
when(fileStorage.getFileSize("owned")).thenReturn(3L);
|
||||
taskManager.setFileResult(
|
||||
"owner-job", "owned", "o.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
assertThat(taskManager.findJobKeyByFileId("owned")).isEqualTo("owner-job");
|
||||
// Local hit must not consult the JobStore.
|
||||
verify(jobStore, never()).findJobIdByFileId(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when JobStore also has no match")
|
||||
void returnsNullWhenJobStoreEmpty() {
|
||||
when(jobStore.findJobIdByFileId("ghost")).thenReturn(Optional.empty());
|
||||
assertThat(taskManager.findJobKeyByFileId("ghost")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates JobStore lookup failures instead of returning null")
|
||||
void propagatesJobStoreFailure() {
|
||||
when(jobStore.findJobIdByFileId("blip"))
|
||||
.thenThrow(new RuntimeException("backplane down"));
|
||||
assertThatThrownBy(() -> taskManager.findJobKeyByFileId("blip"))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("backplane down");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupOldJobs resilience")
|
||||
class CleanupResilience {
|
||||
|
||||
@Test
|
||||
@DisplayName("continues when a file deletion throws during cleanup")
|
||||
void continuesWhenDeleteThrows() throws Exception {
|
||||
String jobId = "old-file-job";
|
||||
taskManager.createTask(jobId);
|
||||
JobResult job = taskManager.getJobResult(jobId);
|
||||
ResultFile rf =
|
||||
ResultFile.builder()
|
||||
.fileId("doomed")
|
||||
.fileName("d.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF_VALUE)
|
||||
.fileSize(1L)
|
||||
.build();
|
||||
ReflectionTestUtils.setField(job, "resultFiles", List.of(rf));
|
||||
ReflectionTestUtils.setField(job, "complete", true);
|
||||
ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2));
|
||||
|
||||
when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked"));
|
||||
|
||||
// Must not propagate; the job is still removed afterwards.
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, JobResult> map =
|
||||
(Map<String, JobResult>)
|
||||
ReflectionTestUtils.getField(taskManager, "jobResults");
|
||||
assertThat(map).doesNotContainKey(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("write-through failures")
|
||||
class WriteThroughFailures {
|
||||
|
||||
@Test
|
||||
@DisplayName("a JobStore put failure does not break createTask")
|
||||
void putFailureSwallowed() {
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("store offline"))
|
||||
.when(jobStore)
|
||||
.put(any(), any());
|
||||
// createTask -> writeThrough; the RuntimeException is caught and logged.
|
||||
taskManager.createTask("wt-job");
|
||||
assertThat(taskManager.getJobResult("wt-job")).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("toEntry mapping")
|
||||
class ToEntryMapping {
|
||||
|
||||
@Test
|
||||
@DisplayName("a failed job maps to FAILED state in the JobStore entry")
|
||||
void failedJobMapsToFailedState() {
|
||||
taskManager.createTask("fail-job");
|
||||
taskManager.setError("fail-job", "kaboom");
|
||||
|
||||
var captor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.common.cluster.JobStoreEntry.class);
|
||||
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
|
||||
assertThat(captor.getValue().jobId()).isEqualTo("fail-job");
|
||||
assertThat(captor.getAllValues())
|
||||
.anySatisfy(
|
||||
e ->
|
||||
assertThat(e.state())
|
||||
.isEqualTo(
|
||||
stirling.software.common.cluster.JobStoreEntry
|
||||
.JobState.FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addNote write-through")
|
||||
class AddNoteWriteThrough {
|
||||
|
||||
@Test
|
||||
@DisplayName("note is reflected in JobStore entry metadata")
|
||||
void noteWritesMetadata() {
|
||||
taskManager.createTask("note-job");
|
||||
assertThat(taskManager.addNote("note-job", "hello")).isTrue();
|
||||
|
||||
var captor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.common.cluster.JobStoreEntry.class);
|
||||
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
|
||||
assertThat(captor.getAllValues())
|
||||
.anySatisfy(e -> assertThat(e.resultMeta()).containsKey("notesCount"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/** Additional coverage for TempFileCleanupService branches not exercised by the base test. */
|
||||
class TempFileCleanupServiceMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private TempFileRegistry registry;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
@Mock private ApplicationProperties.TempFileManagement tempFileManagement;
|
||||
|
||||
@InjectMocks private TempFileCleanupService cleanupService;
|
||||
|
||||
private Path systemTempDir;
|
||||
private Path customTempDir;
|
||||
private Path libreOfficeTempDir;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
|
||||
systemTempDir = tempDir.resolve("systemTemp");
|
||||
customTempDir = tempDir.resolve("customTemp");
|
||||
libreOfficeTempDir = tempDir.resolve("libreOfficeTemp");
|
||||
Files.createDirectories(systemTempDir);
|
||||
Files.createDirectories(customTempDir);
|
||||
Files.createDirectories(libreOfficeTempDir);
|
||||
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(system);
|
||||
lenient().when(system.getTempFileManagement()).thenReturn(tempFileManagement);
|
||||
lenient().when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
|
||||
lenient()
|
||||
.when(tempFileManagement.getLibreofficeDir())
|
||||
.thenReturn(libreOfficeTempDir.toString());
|
||||
lenient().when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
lenient().when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
lenient().when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
|
||||
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
lenient().when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
private static void backdate(Path file, long millisAgo) throws IOException {
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.fromMillis(System.currentTimeMillis() - millisAgo));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isContainerMode")
|
||||
class ContainerMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("Docker and Kubernetes are container modes; others are not")
|
||||
void detectsContainerMachineTypes() {
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Docker");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isTrue();
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Kubernetes");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isTrue();
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSystemTempPath")
|
||||
class SystemTempPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the configured system temp dir when set")
|
||||
void usesConfiguredDir() {
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
Path path =
|
||||
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
|
||||
assertThat(path).isEqualTo(systemTempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to java.io.tmpdir when unset")
|
||||
void fallsBackToJavaTmpDir() {
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn("");
|
||||
Path path =
|
||||
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
|
||||
assertThat(path).isEqualTo(Path.of(System.getProperty("java.io.tmpdir")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("init")
|
||||
class Init {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates configured temp directories that do not yet exist")
|
||||
void createsMissingDirectories() {
|
||||
Path newBase = tempDir.resolve("newBase");
|
||||
Path newLo = tempDir.resolve("newLo");
|
||||
when(tempFileManagement.getBaseTmpDir()).thenReturn(newBase.toString());
|
||||
when(tempFileManagement.getLibreofficeDir()).thenReturn(newLo.toString());
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
|
||||
cleanupService.init();
|
||||
|
||||
assertThat(Files.exists(newBase)).isTrue();
|
||||
assertThat(Files.exists(newLo)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runs startup cleanup when enabled")
|
||||
void runsStartupCleanupWhenEnabled() throws IOException {
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(true);
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// An old stirling temp file in the custom dir should be removed by startup cleanup.
|
||||
Path stale = Files.createFile(customTempDir.resolve("stirling-pdf-stale.tmp"));
|
||||
backdate(stale, 48L * 60 * 60 * 1000); // 48h old, beyond non-container 24h cutoff
|
||||
|
||||
cleanupService.init();
|
||||
|
||||
assertThat(Files.exists(stale)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("scheduledCleanup")
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
// The registered directory was removed by GeneralUtils.deleteDirectory.
|
||||
assertThat(Files.exists(regDir)).isFalse();
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(tempDir.resolve("ghostDir"));
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// No exception even though the directory does not exist.
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
verify(registry).getTempDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupUnregisteredFiles system-temp inclusion")
|
||||
class CleanupUnregistered {
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the system temp dir when cleanupSystemTemp is enabled")
|
||||
void includesSystemTempDir() throws Exception {
|
||||
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(true);
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// Old stirling file in the system temp dir should be deleted in container mode.
|
||||
Path stale = Files.createFile(systemTempDir.resolve("stirling-pdf-sys.tmp"));
|
||||
backdate(stale, 2L * 60 * 60 * 1000); // 2h old
|
||||
|
||||
int deleted =
|
||||
(int)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService,
|
||||
"cleanupUnregisteredFiles",
|
||||
true,
|
||||
true,
|
||||
3600000L);
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||
assertThat(Files.exists(stale)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("registered-file skip and recursion depth")
|
||||
class RegistryAndDepth {
|
||||
|
||||
@Test
|
||||
@DisplayName("a registered file is never deleted")
|
||||
void registeredFilePreserved() throws Exception {
|
||||
Path registered = Files.createFile(systemTempDir.resolve("output_registered.pdf"));
|
||||
backdate(registered, 2L * 60 * 60 * 1000);
|
||||
// The registry reports the file as registered, so cleanup must skip it.
|
||||
when(registry.contains(any(File.class))).thenReturn(true);
|
||||
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, 0, false, 3600000L);
|
||||
|
||||
assertThat(Files.exists(registered)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recursion stops once the maximum depth is exceeded")
|
||||
void recursionDepthGuard() throws Exception {
|
||||
// Starting beyond MAX_RECURSION_DEPTH (5) returns immediately without listing.
|
||||
Path deepFile = Files.createFile(systemTempDir.resolve("output_deep.pdf"));
|
||||
backdate(deepFile, 2L * 60 * 60 * 1000);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, 6, false, 3600000L);
|
||||
|
||||
// Depth guard hit: the file was not visited or deleted.
|
||||
assertThat(Files.exists(deepFile)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupLibreOfficeTempFiles")
|
||||
class LibreOfficeCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("clears contents of registered libreoffice directories but keeps the dir")
|
||||
void clearsLibreOfficeContents() throws IOException {
|
||||
Path loDir = Files.createDirectories(tempDir.resolve("libreoffice-conv"));
|
||||
Path inside = Files.createFile(loDir.resolve("output_lo.pdf"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(loDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
cleanupService.cleanupLibreOfficeTempFiles();
|
||||
|
||||
// The file is removed (age ignored), directory itself remains.
|
||||
assertThat(Files.exists(inside)).isFalse();
|
||||
assertThat(Files.exists(loDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ignores registered directories that are not libreoffice dirs")
|
||||
void ignoresNonLibreOfficeDirs() throws IOException {
|
||||
Path other = Files.createDirectories(tempDir.resolve("other-dir"));
|
||||
Path keep = Files.createFile(other.resolve("output_keep.pdf"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(other);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
|
||||
cleanupService.cleanupLibreOfficeTempFiles();
|
||||
|
||||
// Not a libreoffice dir, so its contents are untouched.
|
||||
assertThat(Files.exists(keep)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupPDFBoxCache")
|
||||
class PdfBoxCache {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes an existing .pdfbox.cache file in the user home")
|
||||
void deletesCacheFile() throws IOException {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("home"));
|
||||
Path cache = Files.createFile(fakeHome.resolve(".pdfbox.cache"));
|
||||
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
|
||||
assertThat(Files.exists(cache)).isFalse();
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("is a no-op when no cache file exists")
|
||||
void noOpWhenNoCache() throws IOException {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("home2"));
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
// No exception when the cache file is absent.
|
||||
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
|
||||
assertThat(Files.exists(fakeHome.resolve(".pdfbox.cache"))).isFalse();
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Point user.home at a throwaway dir so the real ~/.pdfbox.cache is never touched.
|
||||
private void withIsolatedUserHome(Runnable action) {
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("isolated-home"));
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
action.run();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
|
||||
private void invokeCleanupDirectoryStreaming(
|
||||
Path directory, int depth, boolean containerMode, long maxAgeMillis) {
|
||||
try {
|
||||
Consumer<Path> noop = p -> {};
|
||||
var method =
|
||||
TempFileCleanupService.class.getDeclaredMethod(
|
||||
"cleanupDirectoryStreaming",
|
||||
Path.class,
|
||||
boolean.class,
|
||||
int.class,
|
||||
long.class,
|
||||
boolean.class,
|
||||
Consumer.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(
|
||||
cleanupService, directory, containerMode, depth, maxAgeMillis, false, noop);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CbrUtils#convertCbrToPdf}. junrar cannot parse synthetic RAR data,
|
||||
* so these exercise the archive-open failure branches (corrupt header / invalid format) by feeding
|
||||
* non-RAR bytes through a real {@link CustomPDFDocumentFactory} and {@link TempFileManager}. No
|
||||
* external tool is launched.
|
||||
*/
|
||||
class CbrUtilsMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomPDFDocumentFactory factory;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-cbr-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
}
|
||||
|
||||
private static MultipartFile cbr(String filename, byte[] bytes) {
|
||||
return new MockMultipartFile("file", filename, "application/x-cbr", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf - invalid archives")
|
||||
class InvalidArchiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-RAR bytes in a .cbr file are rejected as an invalid archive")
|
||||
void nonRarContentCbr() {
|
||||
byte[] junk = "this is not a rar archive at all".getBytes(StandardCharsets.UTF_8);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("comic.cbr", junk), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-RAR bytes in a .rar file are rejected as an invalid archive")
|
||||
void nonRarContentRar() {
|
||||
byte[] junk = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("archive.rar", junk), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bytes carrying the RAR signature but no valid body are rejected")
|
||||
void rarSignatureOnly() {
|
||||
// "Rar!\x1A\x07\x00" is the classic RAR4 signature; body is missing/garbage.
|
||||
byte[] data = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("comic.cbr", data), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf - validation overload")
|
||||
class ValidationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the 3-arg overload delegates and still validates the extension")
|
||||
void threeArgOverloadValidatesExtension() {
|
||||
MultipartFile wrong = cbr("document.pdf", "x".getBytes(StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(wrong, factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty .cbr file is rejected before archive parsing")
|
||||
void emptyFile() {
|
||||
MultipartFile empty = cbr("comic.cbr", new byte[0]);
|
||||
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(empty, factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isCbrFile additional branches")
|
||||
class IsCbrFileTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a .zip file is not a CBR")
|
||||
void zipIsNotCbr() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
org.mockito.Mockito.when(file.getOriginalFilename()).thenReturn("bundle.zip");
|
||||
assertThat(CbrUtils.isCbrFile(file)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
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.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CbzUtils#convertCbzToPdf} that build real in-memory CBZ (ZIP)
|
||||
* archives containing real PNG images and convert them with a real {@link
|
||||
* CustomPDFDocumentFactory}. No external process is launched (optimizeForEbook is left off so
|
||||
* Ghostscript is never invoked).
|
||||
*/
|
||||
class CbzUtilsMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomPDFDocumentFactory factory;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-cbz-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
}
|
||||
|
||||
private static byte[] pngBytes(Color color) throws IOException {
|
||||
BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "PNG", baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Build a CBZ (ZIP) from name->bytes entries. */
|
||||
private static byte[] buildCbz(String[] names, byte[][] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
if (contents[i] != null) {
|
||||
zos.write(contents[i]);
|
||||
}
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static MultipartFile cbz(byte[] bytes) {
|
||||
return new MockMultipartFile("file", "comic.cbz", "application/x-cbz", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf - happy path")
|
||||
class HappyPathTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a CBZ with two images converts to a two-page PDF, sorted by natural order")
|
||||
void twoImagesToPdf() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"page2.png", "page10.png", "page1.png"},
|
||||
new byte[][] {
|
||||
pngBytes(Color.RED), pngBytes(Color.GREEN), pngBytes(Color.BLUE)
|
||||
});
|
||||
|
||||
try (TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
|
||||
assertThat(resultPdf.exists()).isTrue();
|
||||
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-image entries are ignored, only images become pages")
|
||||
void mixedEntries() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"readme.txt", "cover.png"},
|
||||
new byte[][] {
|
||||
"notes".getBytes(StandardCharsets.UTF_8), pngBytes(Color.CYAN)
|
||||
});
|
||||
|
||||
try (TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
|
||||
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf - invalid archives")
|
||||
class InvalidArchiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty ZIP (no entries) is rejected")
|
||||
void emptyArchive() throws Exception {
|
||||
byte[] archive = buildCbz(new String[] {}, new byte[][] {});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ZIP with no image entries is rejected as 'no images'")
|
||||
void noImageEntries() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"a.txt", "b.json"},
|
||||
new byte[][] {
|
||||
"x".getBytes(StandardCharsets.UTF_8),
|
||||
"{}".getBytes(StandardCharsets.UTF_8)
|
||||
});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-ZIP bytes are rejected as an invalid CBZ format")
|
||||
void corruptArchive() {
|
||||
byte[] notAZip = "this is definitely not a zip file".getBytes(StandardCharsets.UTF_8);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(notAZip), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a CBZ whose only image is corrupt produces no pages and is rejected")
|
||||
void corruptImageProducesNoPages() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"broken.png"},
|
||||
new byte[][] {"not a real png".getBytes(StandardCharsets.UTF_8)});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("@TempDir cleanup")
|
||||
class CleanupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the returned TempFile lives under the configured temp dir and closes cleanly")
|
||||
void tempFileCleanup() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(new String[] {"p.png"}, new byte[][] {pngBytes(Color.MAGENTA)});
|
||||
|
||||
TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false);
|
||||
Path path = resultPdf.getPath();
|
||||
assertThat(Files.exists(path)).isTrue();
|
||||
resultPdf.close();
|
||||
assertThat(Files.exists(path)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.util.EmlParser.EmailAttachment;
|
||||
import stirling.software.common.util.EmlParser.EmailContent;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link EmlParser#extractEmailContent} driven by small real .eml strings.
|
||||
* These exercise the content-building, recipient-formatting and attachment-mapping branches plus
|
||||
* the nested {@link EmailContent}/{@link EmailAttachment} value types. No network or external tool.
|
||||
*/
|
||||
class EmlParserMoreTest {
|
||||
|
||||
private static final String TS = "Mon, 01 Jan 2024 12:00:00 +0000";
|
||||
|
||||
private static byte[] eml(String content) {
|
||||
return content.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static EmlToPdfRequest requestWithAttachments(int maxMb) {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
request.setMaxAttachmentSizeMB(maxMb);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static String simpleText(String from, String to, String subject, String body) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
TS,
|
||||
body);
|
||||
}
|
||||
|
||||
private static String multipartWithAttachment(
|
||||
String boundary, String body, String filename, String attachmentContent) {
|
||||
String encoded =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: a@example.com\nTo: b@example.com\nCc: c@example.com\n"
|
||||
+ "Subject: Multipart\nDate: %s\n"
|
||||
+ "Content-Type: multipart/mixed; boundary=\"%s\"\n\n"
|
||||
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n%s\n\n"
|
||||
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Disposition: attachment; filename=\"%s\"\n"
|
||||
+ "Content-Transfer-Encoding: base64\n\n%s\n\n--%s--",
|
||||
TS,
|
||||
boundary,
|
||||
boundary,
|
||||
body,
|
||||
boundary,
|
||||
filename,
|
||||
encoded,
|
||||
boundary);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - headers and bodies")
|
||||
class HeaderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("subject, from, to and plain-text body are extracted")
|
||||
void plainTextEmail() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
simpleText(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Hello Subject",
|
||||
"Body line one")),
|
||||
null,
|
||||
null);
|
||||
|
||||
assertThat(content.getSubject()).isEqualTo("Hello Subject");
|
||||
assertThat(content.getFrom()).contains("sender@example.com");
|
||||
assertThat(content.getTo()).contains("recipient@example.com");
|
||||
assertThat(content.getTextBody()).contains("Body line one");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the sent date is parsed into a UTC ZonedDateTime")
|
||||
void parsesDate() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "Dated", "hi")), null, null);
|
||||
ZonedDateTime date = content.getDate();
|
||||
assertThat(date).isNotNull();
|
||||
assertThat(date.getYear()).isEqualTo(2024);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an HTML body is captured as the html body")
|
||||
void htmlBodyCaptured() throws Exception {
|
||||
String html =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"From: a@x.com\nTo: b@x.com\nSubject: HtmlMail\nDate: %s\n"
|
||||
+ "Content-Type: text/html; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "<html><body><p>Rich</p></body></html>",
|
||||
TS);
|
||||
|
||||
EmailContent content = EmlParser.extractEmailContent(eml(html), null, null);
|
||||
assertThat(content.getHtmlBody()).contains("Rich");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - attachments")
|
||||
class AttachmentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("attachment metadata is mapped and CC recipients are formatted")
|
||||
void attachmentMappedAndCc() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b1",
|
||||
"see attached",
|
||||
"notes.txt",
|
||||
"attachment payload")),
|
||||
requestWithAttachments(10),
|
||||
null);
|
||||
|
||||
assertThat(content.getCc()).contains("c@example.com");
|
||||
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
|
||||
EmailAttachment att = content.getAttachments().get(0);
|
||||
assertThat(att.getFilename()).isEqualTo("notes.txt");
|
||||
assertThat(att.getData()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("when attachments are not requested the data bytes are omitted")
|
||||
void attachmentDataOmittedWhenNotRequested() throws Exception {
|
||||
EmlToPdfRequest noAttach = new EmlToPdfRequest();
|
||||
noAttach.setIncludeAttachments(false);
|
||||
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b2", "body", "doc.txt", "some content")),
|
||||
noAttach,
|
||||
null);
|
||||
|
||||
// Metadata still present, but the raw bytes are not attached.
|
||||
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(content.getAttachments().get(0).getData()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an attachment over the size limit has its data skipped")
|
||||
void attachmentOverSizeLimitSkipped() throws Exception {
|
||||
// 0 MB limit means any non-empty attachment exceeds it.
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b3",
|
||||
"body",
|
||||
"big.txt",
|
||||
"this content exceeds the zero-byte limit")),
|
||||
requestWithAttachments(0),
|
||||
null);
|
||||
|
||||
assertThat(content.getAttachments().get(0).getData()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - failure paths")
|
||||
class FailureTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("OLE2 magic bytes that are not a real MSG file raise an IOException")
|
||||
void fakeMsgFile() {
|
||||
// OLE2/MSG magic prefix followed by garbage -> outlookMsgToEmail fails.
|
||||
byte[] fakeMsg = {
|
||||
(byte) 0xD0,
|
||||
(byte) 0xCF,
|
||||
(byte) 0x11,
|
||||
(byte) 0xE0,
|
||||
(byte) 0xA1,
|
||||
(byte) 0xB1,
|
||||
(byte) 0x1A,
|
||||
(byte) 0xE1,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
0x05,
|
||||
0x06,
|
||||
0x07
|
||||
};
|
||||
assertThatThrownBy(() -> EmlParser.extractEmailContent(fakeMsg, null, null))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EmailContent value type")
|
||||
class EmailContentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setHtmlBody and setTextBody strip carriage returns")
|
||||
void stripsCarriageReturns() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
|
||||
content.setHtmlBody("line1\r\nline2");
|
||||
content.setTextBody("a\r\nb");
|
||||
assertThat(content.getHtmlBody()).doesNotContain("\r");
|
||||
assertThat(content.getTextBody()).doesNotContain("\r");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null bodies are preserved as null")
|
||||
void nullBodiesPreserved() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
|
||||
content.setHtmlBody(null);
|
||||
assertThat(content.getHtmlBody()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EmailAttachment value type")
|
||||
class EmailAttachmentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setData updates the size in bytes")
|
||||
void setDataUpdatesSize() {
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setData(new byte[] {1, 2, 3, 4, 5});
|
||||
assertThat(att.getSizeBytes()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setData with null leaves size unchanged")
|
||||
void setDataNull() {
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setData(null);
|
||||
assertThat(att.getSizeBytes()).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.util.EmlParser.EmailAttachment;
|
||||
import stirling.software.common.util.EmlParser.EmailContent;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for the HTML-generation and helper methods of {@link EmlProcessingUtils}. All
|
||||
* inputs are built in-memory; no sanitizer, network or external tool is used.
|
||||
*/
|
||||
class EmlProcessingUtilsMoreTest {
|
||||
|
||||
private static EmailContent content(String subject, String from, String to) {
|
||||
EmailContent content = new EmailContent();
|
||||
content.setSubject(subject);
|
||||
content.setFrom(from);
|
||||
content.setTo(to);
|
||||
return content;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateEnhancedEmailHtml")
|
||||
class GenerateHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("produces a full HTML document with the subject and core headers")
|
||||
void basicDocument() {
|
||||
EmailContent content = content("My Subject", "from@x.com", "to@x.com");
|
||||
content.setTextBody("plain body text");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("<!DOCTYPE html>")
|
||||
.contains("My Subject")
|
||||
.contains("from@x.com")
|
||||
.contains("to@x.com")
|
||||
.contains("plain body text")
|
||||
.contains("</body></html>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renders CC, BCC and a formatted date when present")
|
||||
void ccBccAndDate() {
|
||||
EmailContent content = content("Sub", "from@x.com", "to@x.com");
|
||||
content.setCc("cc@x.com");
|
||||
content.setBcc("bcc@x.com");
|
||||
content.setDate(ZonedDateTime.of(2024, 5, 6, 7, 8, 0, 0, ZoneOffset.UTC));
|
||||
content.setTextBody("hi");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("CC:")
|
||||
.contains("cc@x.com")
|
||||
.contains("BCC:")
|
||||
.contains("bcc@x.com")
|
||||
.contains("Date:");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("prefers the HTML body over the text body when both are present")
|
||||
void prefersHtmlBody() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setHtmlBody("<p>html version</p>");
|
||||
content.setTextBody("text version");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("html version");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to a no-content placeholder when both bodies are empty")
|
||||
void noContentPlaceholder() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("No content available");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renders an attachments section and respects includeAttachments wording")
|
||||
void attachmentsSection() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setTextBody("body");
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setFilename("file.pdf");
|
||||
att.setContentType("application/pdf");
|
||||
att.setData(new byte[] {1, 2, 3});
|
||||
List<EmailAttachment> list = new ArrayList<>();
|
||||
list.add(att);
|
||||
content.setAttachments(list);
|
||||
content.setAttachmentCount(1);
|
||||
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, request, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("Attachments (1)")
|
||||
.contains("file.pdf")
|
||||
.contains("embedded in the file");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shows the not-included note when attachments are not requested")
|
||||
void attachmentsNotIncludedNote() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setTextBody("body");
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setFilename("a.txt");
|
||||
List<EmailAttachment> list = new ArrayList<>();
|
||||
list.add(att);
|
||||
content.setAttachments(list);
|
||||
content.setAttachmentCount(1);
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("files not included in PDF");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createHtmlRequest")
|
||||
class CreateHtmlRequestTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("copies the file input and applies the default zoom")
|
||||
void copiesFileInputAndZoom() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
|
||||
assertThat(htmlRequest).isNotNull();
|
||||
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tolerates a null request and still sets the zoom")
|
||||
void nullRequest() {
|
||||
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(null);
|
||||
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("simplifyHtmlContent")
|
||||
class SimplifyHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("strips script and style tags")
|
||||
void stripsScriptAndStyle() {
|
||||
String html =
|
||||
"<html><head><style>.a{}</style></head>"
|
||||
+ "<body><script>alert(1)</script><p>keep</p></body></html>";
|
||||
String result = EmlProcessingUtils.simplifyHtmlContent(html);
|
||||
assertThat(result).doesNotContain("<script").doesNotContain("<style").contains("keep");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("decodeMimeHeader - quoted-printable charset handling")
|
||||
class DecodeMimeHeaderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodes a quoted-printable hex sequence into the right characters")
|
||||
void decodesQpHex() {
|
||||
// =E9 in ISO-8859-1 is 'é'.
|
||||
String result = EmlProcessingUtils.decodeMimeHeader("=?ISO-8859-1?Q?caf=E9?=");
|
||||
assertThat(result).isEqualTo("café");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown charset falls back without throwing")
|
||||
void unknownCharsetFallback() {
|
||||
String result = EmlProcessingUtils.decodeMimeHeader("=?MADE-UP-CHARSET?B?SGVsbG8=?=");
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertTextToHtml - sanitizer-less escaping")
|
||||
class ConvertTextToHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("escapes HTML special characters when no sanitizer is supplied")
|
||||
void escapesSpecialChars() {
|
||||
String result = EmlProcessingUtils.convertTextToHtml("a <b> & c", null);
|
||||
assertThat(result).contains("<b>").contains("&");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectMimeType - extension table")
|
||||
class DetectMimeTypeTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("detects svg, bmp and webp from the filename")
|
||||
void detectsExtraTypes() {
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.svg", null)).isEqualTo("image/svg+xml");
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.bmp", null)).isEqualTo("image/bmp");
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.webp", null)).isEqualTo("image/webp");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.ErrorCode;
|
||||
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
|
||||
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
|
||||
|
||||
/**
|
||||
* Remaining-gap tests for {@link ExceptionUtils} not already covered by ExceptionUtilsTest /
|
||||
* ExceptionUtilsGapTest: the two-argument Ghostscript factory, the cause-bearing exception
|
||||
* constructors, and the EPS-multipage Ghostscript diagnostic branch.
|
||||
*/
|
||||
class ExceptionUtilsExtraTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("createGhostscriptCompressionException(processOutput, cause)")
|
||||
class TwoArgGhostscriptTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("both output and cause provided yields a coded exception with the cause")
|
||||
void outputAndCause() {
|
||||
Exception cause = new RuntimeException("boom");
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException(
|
||||
"Some informational chatter", cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("EPS-multipage marker is recognized as a page-drawing error")
|
||||
void epsMultipageMarker() {
|
||||
String output = "Page 1\nEPS files may not contain multiple pages";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
assertNotNull(ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-string overload with informational output uses compression code")
|
||||
void singleStringInformational() {
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException("just chatter");
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
// The fallback informative line is appended to the base message.
|
||||
assertTrue(ex.getMessage().contains("chatter"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cause-bearing exception constructors")
|
||||
class CauseConstructorTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("CbrFormatException(message, cause, code) retains cause and code")
|
||||
void cbrWithCause() {
|
||||
Exception cause = new IllegalStateException("rar");
|
||||
CbrFormatException ex = new CbrFormatException("bad cbr", cause, "E010");
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals("E010", ex.getErrorCode());
|
||||
assertEquals("bad cbr", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CbzFormatException(message, code) leaves cause null")
|
||||
void cbzNoCause() {
|
||||
CbzFormatException ex = new CbzFormatException("bad cbz", "E015");
|
||||
assertEquals("E015", ex.getErrorCode());
|
||||
assertEquals("bad cbz", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FfmpegRequiredException(message, cause, code) retains the cause")
|
||||
void ffmpegWithCause() {
|
||||
Exception cause = new RuntimeException("no ffmpeg");
|
||||
FfmpegRequiredException ex =
|
||||
new FfmpegRequiredException("ffmpeg missing", cause, "E063");
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals("E063", ex.getErrorCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
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.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils.BaseAppException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.EmlFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.ErrorCode;
|
||||
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
|
||||
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
|
||||
import stirling.software.common.util.ExceptionUtils.OutOfMemoryDpiException;
|
||||
import stirling.software.common.util.ExceptionUtils.PdfCorruptedException;
|
||||
|
||||
/**
|
||||
* Additional gap-filling unit tests for {@link ExceptionUtils}, covering areas not exercised by
|
||||
* {@code ExceptionUtilsTest}: CBR/CBZ/EML factories, error-code hint/action lookups, rendering
|
||||
* dimension validation, OOM rendering wrappers, Ghostscript output analysis, and wrapException.
|
||||
*
|
||||
* <p>The {@code messages} ResourceBundle is not on the common module test classpath, so {@link
|
||||
* ExceptionUtils} falls back to the default messages baked into {@link ErrorCode}. Assertions here
|
||||
* rely only on those default messages and on deterministic structural behavior.
|
||||
*/
|
||||
class ExceptionUtilsGapTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("ErrorCode enum metadata")
|
||||
class ErrorCodeMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("each error code exposes code, message key and default message")
|
||||
void allErrorCodesHaveMetadata() {
|
||||
for (ErrorCode code : ErrorCode.values()) {
|
||||
assertNotNull(code.getCode(), "code for " + code);
|
||||
assertTrue(code.getCode().startsWith("E"), "code prefix for " + code);
|
||||
assertNotNull(code.getMessageKey(), "messageKey for " + code);
|
||||
assertNotNull(code.getDefaultMessage(), "defaultMessage for " + code);
|
||||
assertFalse(code.getDefaultMessage().isEmpty(), "defaultMessage empty for " + code);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known error codes map to expected identifiers")
|
||||
void knownErrorCodeIdentifiers() {
|
||||
assertEquals("E001", ErrorCode.PDF_CORRUPTED.getCode());
|
||||
assertEquals("E081", ErrorCode.OUT_OF_MEMORY_DPI.getCode());
|
||||
assertEquals("error.pdfCorrupted", ErrorCode.PDF_CORRUPTED.getMessageKey());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Hints and action lookups via resource bundle")
|
||||
class HintAndActionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("getHintsForErrorCode returns empty list for null code")
|
||||
void hintsNullCode() {
|
||||
assertEquals(List.of(), ExceptionUtils.getHintsForErrorCode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getHintsForErrorCode returns empty list when no hints exist in bundle")
|
||||
void hintsMissingFromBundle() {
|
||||
// Fallback empty bundle has no hint keys, so the result is an empty list.
|
||||
List<String> hints = ExceptionUtils.getHintsForErrorCode("E001");
|
||||
assertNotNull(hints);
|
||||
assertTrue(hints.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getActionRequiredForErrorCode returns null for null code")
|
||||
void actionNullCode() {
|
||||
assertNull(ExceptionUtils.getActionRequiredForErrorCode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getActionRequiredForErrorCode returns null when key absent from bundle")
|
||||
void actionMissingFromBundle() {
|
||||
assertNull(ExceptionUtils.getActionRequiredForErrorCode("E001"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("CBR format exception factories")
|
||||
class CbrFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid format uses provided message when non-null")
|
||||
void cbrInvalidFormatWithMessage() {
|
||||
CbrFormatException ex =
|
||||
ExceptionUtils.createCbrInvalidFormatException("custom cbr msg");
|
||||
assertEquals("custom cbr msg", ex.getMessage());
|
||||
assertEquals(ErrorCode.CBR_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid format falls back to default message when null")
|
||||
void cbrInvalidFormatNullMessage() {
|
||||
CbrFormatException ex = ExceptionUtils.createCbrInvalidFormatException(null);
|
||||
assertTrue(ex.getMessage().contains("CBR/RAR archive"));
|
||||
assertEquals("E010", ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encrypted CBR reuses invalid-format code")
|
||||
void cbrEncrypted() {
|
||||
CbrFormatException ex = ExceptionUtils.createCbrEncryptedException();
|
||||
assertEquals(ErrorCode.CBR_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no images and corrupted images both map to CBR_NO_IMAGES")
|
||||
void cbrNoImages() {
|
||||
CbrFormatException noImages = ExceptionUtils.createCbrNoImagesException();
|
||||
CbrFormatException corrupted = ExceptionUtils.createCbrCorruptedImagesException();
|
||||
assertEquals(ErrorCode.CBR_NO_IMAGES.getCode(), noImages.getErrorCode());
|
||||
assertEquals(ErrorCode.CBR_NO_IMAGES.getCode(), corrupted.getErrorCode());
|
||||
assertTrue(noImages.getMessage().contains("No valid images"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("not-a-CBR file uses CBR_NOT_CBR code")
|
||||
void notCbr() {
|
||||
CbrFormatException ex = ExceptionUtils.createNotCbrFileException();
|
||||
assertEquals(ErrorCode.CBR_NOT_CBR.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("CBR or RAR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"CbrFormatException is an IllegalArgumentException via BaseValidationException")
|
||||
void cbrIsIllegalArgument() {
|
||||
CbrFormatException ex = ExceptionUtils.createNotCbrFileException();
|
||||
assertInstanceOf(IllegalArgumentException.class, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("CBZ format exception factories")
|
||||
class CbzFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid format wraps cause and uses CBZ_INVALID_FORMAT code")
|
||||
void cbzInvalidFormat() {
|
||||
Exception cause = new Exception("zip boom");
|
||||
CbzFormatException ex = ExceptionUtils.createCbzInvalidFormatException(cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.CBZ_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("CBZ/ZIP archive"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty CBZ reuses invalid-format code")
|
||||
void cbzEmpty() {
|
||||
CbzFormatException ex = ExceptionUtils.createCbzEmptyException();
|
||||
assertEquals(ErrorCode.CBZ_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no images and corrupted images both map to CBZ_NO_IMAGES")
|
||||
void cbzNoImages() {
|
||||
CbzFormatException noImages = ExceptionUtils.createCbzNoImagesException();
|
||||
CbzFormatException corrupted = ExceptionUtils.createCbzCorruptedImagesException();
|
||||
assertEquals(ErrorCode.CBZ_NO_IMAGES.getCode(), noImages.getErrorCode());
|
||||
assertEquals(ErrorCode.CBZ_NO_IMAGES.getCode(), corrupted.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("not-a-CBZ file uses CBZ_NOT_CBZ code")
|
||||
void notCbz() {
|
||||
CbzFormatException ex = ExceptionUtils.createNotCbzFileException();
|
||||
assertEquals(ErrorCode.CBZ_NOT_CBZ.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("CBZ or ZIP"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EML format exception factories")
|
||||
class EmlFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty EML uses EML_EMPTY code")
|
||||
void emlEmpty() {
|
||||
EmlFormatException ex = ExceptionUtils.createEmlEmptyException();
|
||||
assertEquals(ErrorCode.EML_EMPTY.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("EML file is empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid EML uses EML_INVALID_FORMAT code")
|
||||
void emlInvalid() {
|
||||
EmlFormatException ex = ExceptionUtils.createEmlInvalidFormatException();
|
||||
assertEquals(ErrorCode.EML_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("Invalid EML"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Image, OCR and processing factories")
|
||||
class ImageOcrProcessingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("image read exception embeds filename and has no cause")
|
||||
void imageRead() {
|
||||
IOException ex = ExceptionUtils.createImageReadException("photo.png");
|
||||
assertTrue(ex.getMessage().contains("photo.png"));
|
||||
assertNull(ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image read exception rejects null filename")
|
||||
void imageReadNullFilename() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createImageReadException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ocr invalid render type uses default message")
|
||||
void ocrInvalidRenderType() {
|
||||
IOException ex = ExceptionUtils.createOcrInvalidRenderTypeException();
|
||||
assertTrue(ex.getMessage().contains("hocr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ocr processing failed includes return code")
|
||||
void ocrProcessingFailed() {
|
||||
IOException ex = ExceptionUtils.createOcrProcessingFailedException(7);
|
||||
assertTrue(ex.getMessage().contains("7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("processing interrupted wraps the InterruptedException cause")
|
||||
void processingInterrupted() {
|
||||
InterruptedException cause = new InterruptedException("stop");
|
||||
IOException ex =
|
||||
ExceptionUtils.createProcessingInterruptedException("compression", cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertTrue(ex.getMessage().contains("compression"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("processing interrupted rejects null arguments")
|
||||
void processingInterruptedNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
ExceptionUtils.createProcessingInterruptedException(
|
||||
null, new InterruptedException()));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createProcessingInterruptedException("x", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ghostscript conversion exception embeds output type")
|
||||
void ghostscriptConversion() {
|
||||
IOException ex = ExceptionUtils.createGhostscriptConversionException("png");
|
||||
assertNotNull(ex.getMessage());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createGhostscriptConversionException(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Validation factories: page size, file, ffmpeg")
|
||||
class ValidationFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid page size rejects null size")
|
||||
void invalidPageSizeNull() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createInvalidPageSizeException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file null-or-empty uses FILE_NULL_OR_EMPTY default message")
|
||||
void fileNullOrEmpty() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createFileNullOrEmptyException();
|
||||
assertTrue(ex.getMessage().contains("null or empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file no-name uses FILE_NO_NAME default message")
|
||||
void fileNoName() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createFileNoNameException();
|
||||
assertTrue(ex.getMessage().contains("must have a name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pdf no-pages uses PDF_NO_PAGES default message")
|
||||
void pdfNoPages() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createPdfNoPages();
|
||||
assertTrue(ex.getMessage().contains("no pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ffmpeg required exception exposes FFMPEG_REQUIRED code and null cause")
|
||||
void ffmpegRequired() {
|
||||
FfmpegRequiredException ex = ExceptionUtils.createFfmpegRequiredException();
|
||||
assertEquals(ErrorCode.FFMPEG_REQUIRED.getCode(), ex.getErrorCode());
|
||||
assertNull(ex.getCause());
|
||||
assertTrue(ex.getMessage().contains("FFmpeg"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ErrorCode-based argument and IO factories")
|
||||
class ErrorCodeArgFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("createIllegalArgumentException(ErrorCode, args) formats default message")
|
||||
void illegalArgumentFromErrorCode() {
|
||||
IllegalArgumentException ex =
|
||||
ExceptionUtils.createIllegalArgumentException(
|
||||
ErrorCode.INVALID_PAGE_SIZE, "B7");
|
||||
assertTrue(ex.getMessage().contains("B7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createIllegalArgumentException rejects null ErrorCode")
|
||||
void illegalArgumentFromNullErrorCode() {
|
||||
ErrorCode nullCode = null;
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createIllegalArgumentException(nullCode));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createFileProcessingException rejects null operation and cause")
|
||||
void fileProcessingNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createFileProcessingException(null, new Exception()));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createFileProcessingException("op", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createInvalidArgumentException rejects null name or value")
|
||||
void invalidArgumentNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createInvalidArgumentException(null, "v"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createInvalidArgumentException("n", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNullArgumentException rejects null argument name")
|
||||
void nullArgumentNullName() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createNullArgumentException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createIOException without cause leaves cause null")
|
||||
void ioExceptionWithoutCause() {
|
||||
IOException ex = ExceptionUtils.createIOException("key", "msg {0}", null, "A");
|
||||
assertEquals("msg A", ex.getMessage());
|
||||
assertNull(ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createRuntimeException without cause leaves cause null")
|
||||
void runtimeExceptionWithoutCause() {
|
||||
RuntimeException ex =
|
||||
ExceptionUtils.createRuntimeException("key", "msg {0}", null, "B");
|
||||
assertEquals("msg B", ex.getMessage());
|
||||
assertNull(ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPdfCorruptedException null-cause handling")
|
||||
class PdfCorruptedCauseTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null cause")
|
||||
void nullCause() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createPdfCorruptedException("ctx", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty context behaves like no context")
|
||||
void emptyContext() {
|
||||
PdfCorruptedException ex =
|
||||
ExceptionUtils.createPdfCorruptedException("", new Exception("x"));
|
||||
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
|
||||
assertEquals(ErrorCode.PDF_CORRUPTED.getCode(), ex.getErrorCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("validateRenderingDimensions")
|
||||
class ValidateRenderingDimensionsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null page is a no-op")
|
||||
void nullPage() {
|
||||
// Should simply return without throwing.
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> ExceptionUtils.validateRenderingDimensions(null, 1, 300));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("normal letter-size page at 150 DPI passes validation")
|
||||
void normalPagePasses() {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> ExceptionUtils.validateRenderingDimensions(page, 1, 150));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with zero DPI yields zero pixels and passes")
|
||||
void zeroDpiPasses() {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> ExceptionUtils.validateRenderingDimensions(page, 2, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handleOomRendering wrappers")
|
||||
class HandleOomRenderingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns operation result on success (with page number)")
|
||||
void successWithPage() throws IOException {
|
||||
String result = ExceptionUtils.handleOomRendering(3, 300, () -> "ok");
|
||||
assertEquals("ok", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns operation result on success (no page number)")
|
||||
void successNoPage() throws IOException {
|
||||
String result = ExceptionUtils.handleOomRendering(300, () -> "fine");
|
||||
assertEquals("fine", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates IOException from the operation unchanged")
|
||||
void propagatesIoException() {
|
||||
IOException boom = new IOException("io boom");
|
||||
IOException thrown =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() ->
|
||||
ExceptionUtils.handleOomRendering(
|
||||
1,
|
||||
300,
|
||||
() -> {
|
||||
throw boom;
|
||||
}));
|
||||
assertSame(boom, thrown);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converts OutOfMemoryError to OutOfMemoryDpiException (with page)")
|
||||
void oomToDpiExceptionWithPage() {
|
||||
OutOfMemoryDpiException thrown =
|
||||
assertThrows(
|
||||
OutOfMemoryDpiException.class,
|
||||
() ->
|
||||
ExceptionUtils.handleOomRendering(
|
||||
5,
|
||||
300,
|
||||
() -> {
|
||||
throw new OutOfMemoryError("heap");
|
||||
}));
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), thrown.getErrorCode());
|
||||
assertInstanceOf(OutOfMemoryError.class, thrown.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converts NegativeArraySizeException to OutOfMemoryDpiException (no page)")
|
||||
void negativeArraySizeToDpiExceptionNoPage() {
|
||||
OutOfMemoryDpiException thrown =
|
||||
assertThrows(
|
||||
OutOfMemoryDpiException.class,
|
||||
() ->
|
||||
ExceptionUtils.handleOomRendering(
|
||||
300,
|
||||
() -> {
|
||||
throw new NegativeArraySizeException("-1");
|
||||
}));
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), thrown.getErrorCode());
|
||||
assertInstanceOf(NegativeArraySizeException.class, thrown.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createOutOfMemoryDpiException overloads")
|
||||
class OutOfMemoryDpiFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("page + dpi + Throwable wraps cause and sets code")
|
||||
void pageDpiThrowable() {
|
||||
Throwable cause = new IllegalStateException("too big");
|
||||
OutOfMemoryDpiException ex =
|
||||
ExceptionUtils.createOutOfMemoryDpiException(4, 600, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page + dpi + OutOfMemoryError overload wraps the error")
|
||||
void pageDpiOomError() {
|
||||
OutOfMemoryError cause = new OutOfMemoryError("oom");
|
||||
OutOfMemoryDpiException ex =
|
||||
ExceptionUtils.createOutOfMemoryDpiException(2, 300, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dpi + Throwable overload wraps cause")
|
||||
void dpiThrowable() {
|
||||
Throwable cause = new RuntimeException("x");
|
||||
OutOfMemoryDpiException ex = ExceptionUtils.createOutOfMemoryDpiException(300, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dpi + OutOfMemoryError overload wraps the error")
|
||||
void dpiOomError() {
|
||||
OutOfMemoryError cause = new OutOfMemoryError("oom");
|
||||
OutOfMemoryDpiException ex = ExceptionUtils.createOutOfMemoryDpiException(300, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null cause")
|
||||
void nullCause() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createOutOfMemoryDpiException(1, 300, (Throwable) null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Ghostscript output analysis")
|
||||
class GhostscriptAnalysisTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null/blank output produces generic compression exception")
|
||||
void blankOutput() {
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(" ");
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recognized page drawing error yields page-drawing error code")
|
||||
void pageDrawingError() {
|
||||
String output = "Page 3\nERROR: page drawing error encountered while processing";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-page-drawing output falls back to compression error code")
|
||||
void unrecognizedOutput() {
|
||||
String output = "Some random ghostscript chatter that is not an error marker";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("detectGhostscriptCriticalError returns exception only for critical output")
|
||||
void detectCritical() {
|
||||
GhostscriptException critical =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(
|
||||
"Page 1\ncould not draw this page");
|
||||
assertNotNull(critical);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), critical.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("detectGhostscriptCriticalError returns null for non-critical output")
|
||||
void detectNonCritical() {
|
||||
assertNull(ExceptionUtils.detectGhostscriptCriticalError("just informational output"));
|
||||
assertNull(ExceptionUtils.detectGhostscriptCriticalError(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("compression exception derived from cause message")
|
||||
void compressionFromCauseMessage() {
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException(
|
||||
new Exception("Page 2\npage drawing error"));
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createGhostscriptCompressionException rejects null cause overload")
|
||||
void compressionNullCause() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createGhostscriptCompressionException((Exception) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple affected pages are summarized in the message")
|
||||
void multiplePages() {
|
||||
String output = "Page 1\npage drawing error\nPage 2\ncould not draw this page";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
assertNotNull(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("wrapException")
|
||||
class WrapExceptionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("RuntimeException is returned unchanged")
|
||||
void runtimePassthrough() {
|
||||
RuntimeException original = new IllegalStateException("boom");
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(original, "merge");
|
||||
assertSame(original, wrapped);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BaseAppException (IOException subtype) is wrapped in a RuntimeException")
|
||||
void baseAppExceptionWrapped() {
|
||||
// A corrupted-pdf IOException triggers handlePdfException -> PdfCorruptedException.
|
||||
IOException corrupted = new IOException("Invalid PDF");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(corrupted)).thenReturn(true);
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(corrupted, "merge");
|
||||
assertInstanceOf(BaseAppException.class, wrapped.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain IOException is wrapped via file-processing exception")
|
||||
void plainIoExceptionWrapped() {
|
||||
IOException io = new IOException("disk full");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(io)).thenReturn(false);
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(io, "split");
|
||||
assertInstanceOf(IOException.class, wrapped.getCause());
|
||||
assertFalse(wrapped.getCause() instanceof BaseAppException);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("checked non-IO exception is wrapped with operation context")
|
||||
void checkedExceptionWrapped() {
|
||||
Exception checked = new Exception("oops");
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(checked, "convert");
|
||||
assertSame(checked, wrapped.getCause());
|
||||
assertTrue(wrapped.getMessage().contains("convert"));
|
||||
assertTrue(wrapped.getMessage().contains("oops"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null exception or operation")
|
||||
void wrapNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ExceptionUtils.wrapException(null, "op"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.wrapException(new Exception(), null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("logException return value and handlePdfException null guard")
|
||||
class LogAndHandleTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("logException returns the same exception instance for fluent throw")
|
||||
void logExceptionReturnsSame() {
|
||||
Exception e = new RuntimeException("unexpected");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
|
||||
Exception returned = ExceptionUtils.logException("op", e);
|
||||
assertSame(e, returned);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logException rejects null operation or exception")
|
||||
void logExceptionNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.logException(null, new Exception()));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ExceptionUtils.logException("op", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handlePdfException rejects null exception")
|
||||
void handlePdfNull() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ExceptionUtils.handlePdfException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handlePdfException with context wraps corrupted PDF and includes context")
|
||||
void handlePdfWithContext() {
|
||||
IOException original = new IOException("damaged");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true);
|
||||
IOException result = ExceptionUtils.handlePdfException(original, "during merge");
|
||||
assertInstanceOf(PdfCorruptedException.class, result);
|
||||
assertTrue(result.getMessage().contains("during merge"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
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 static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link FileMonitor}, focusing on {@code isFileReadyForProcessing} branches
|
||||
* (stale-timestamp ready path, active file-lock not-ready path) and {@code trackFiles} processing
|
||||
* of real filesystem create/modify events. Timing-sensitive readiness is forced via explicit
|
||||
* last-modified timestamps rather than sleeps to stay non-flaky.
|
||||
*/
|
||||
class FileMonitorMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private FileMonitor monitorWatching(Path watchDir, Predicate<Path> filter) throws IOException {
|
||||
RuntimePathConfig config = mock(RuntimePathConfig.class);
|
||||
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(watchDir.toString()));
|
||||
return new FileMonitor(filter, config);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isFileReadyForProcessing")
|
||||
class ReadinessTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("file with an old last-modified time and no lock is ready")
|
||||
void staleFileIsReady() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("ready.pdf");
|
||||
Files.writeString(file, "data");
|
||||
// Backdate well beyond the 5000ms freshness window so the timestamp branch marks ready.
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
|
||||
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stale file lock is acquired and released so readiness stays true")
|
||||
void staleUnlockedFileLockRoundTrips() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("roundtrip.pdf");
|
||||
Files.writeString(file, "data");
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
|
||||
|
||||
// First call acquires+releases a lock and returns ready; a second call still works,
|
||||
// proving the lock was released (no lingering handle).
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recently modified, unlocked file is not yet ready")
|
||||
void freshFileNotReady() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("fresh.pdf");
|
||||
Files.writeString(file, "data");
|
||||
// Just-written file is within the freshness window and not in the ready list.
|
||||
assertFalse(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("trackFiles event processing")
|
||||
class TrackFilesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-existing files are registered during construction")
|
||||
void preExistingFilesRegistered() throws IOException {
|
||||
Files.writeString(tempDir.resolve("existing.txt"), "x");
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
assertNotNull(monitor);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-existing nested directories are registered recursively")
|
||||
void nestedDirectoriesRegistered() throws IOException {
|
||||
Path nested = tempDir.resolve("sub");
|
||||
Files.createDirectories(nested);
|
||||
Files.writeString(nested.resolve("inner.txt"), "y");
|
||||
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
assertNotNull(monitor);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("create then modify then delete cycle is processed without error")
|
||||
void createModifyDeleteCycle() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
|
||||
Path file = tempDir.resolve("cycle.txt");
|
||||
Files.writeString(file, "one");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
|
||||
Files.writeString(file, "two-modified-content");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
|
||||
Files.delete(file);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a rejecting path filter still lets trackFiles run cleanly")
|
||||
void rejectingFilter() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> false);
|
||||
Files.writeString(tempDir.resolve("ignored.txt"), "z");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("subdirectory created after start is handled on the next tick")
|
||||
void subdirectoryCreatedAfterStart() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
// First tick establishes monitoring; then create a child directory + file.
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
Path newDir = tempDir.resolve("late");
|
||||
Files.createDirectories(newDir);
|
||||
Files.writeString(newDir.resolve("late.txt"), "late");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("re-registration safety net")
|
||||
class ReRegistrationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("trackFiles re-registers root dirs when nothing is currently mapped")
|
||||
void reRegistersWhenEmpty() throws IOException {
|
||||
// Root directory does not exist at construction, so nothing is registered.
|
||||
Path missing = tempDir.resolve("appears-later");
|
||||
FileMonitor monitor = monitorWatching(missing, p -> true);
|
||||
|
||||
// Now create the directory; the next tick should attempt re-registration.
|
||||
Files.createDirectories(missing);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
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.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link FileToPdf#convertHtmlToPdf}. The WeasyPrint process is fully mocked
|
||||
* via {@link MockedStatic} so the command-building, sanitization and ZIP repacking paths run
|
||||
* without launching any external tool.
|
||||
*/
|
||||
class FileToPdfMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomHtmlSanitizer sanitizer;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-htmlpdf-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
|
||||
sanitizer = mock(CustomHtmlSanitizer.class);
|
||||
// Identity sanitize so content is preserved for assertions.
|
||||
when(sanitizer.sanitize(Mockito.anyString()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
/** Build a real ZIP byte[] from name->content pairs. */
|
||||
private static byte[] buildZip(String[] names, String[] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
zos.write(contents[i].getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** mockStatic helper returning a captor of the command list passed to the executor. */
|
||||
private ProcessExecutorResult successResult() {
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
when(result.getRc()).thenReturn(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - HTML input")
|
||||
class HtmlInputTests {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
@DisplayName("builds the WeasyPrint command and returns the output bytes")
|
||||
void htmlHappyPath() throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(commandCaptor.capture());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"/usr/bin/weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8),
|
||||
"page.html",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
List<String> command = commandCaptor.getValue();
|
||||
assertThat(command.get(0)).isEqualTo("/usr/bin/weasyprint");
|
||||
assertThat(command).contains("--pdf-forms", "-e", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the HTML body is passed through the sanitizer before writing")
|
||||
void htmlIsSanitized() throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"<b>x</b>".getBytes(StandardCharsets.UTF_8),
|
||||
"doc.HTML",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
Mockito.verify(sanitizer).sanitize("<b>x</b>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - ZIP input")
|
||||
class ZipInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("html entries inside the ZIP are sanitized and repacked")
|
||||
void zipHtmlEntriesSanitized() throws Exception {
|
||||
byte[] zip =
|
||||
buildZip(
|
||||
new String[] {"index.html", "asset.css"},
|
||||
new String[] {"<p>body</p>", "p{color:red}"});
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
// Only the .html entry should be sanitized, not the .css.
|
||||
Mockito.verify(sanitizer).sanitize("<p>body</p>");
|
||||
Mockito.verify(sanitizer, Mockito.never()).sanitize("p{color:red}");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-html entries inside the ZIP are copied through unchanged")
|
||||
void zipNonHtmlCopied() throws Exception {
|
||||
byte[] zip = buildZip(new String[] {"data.txt"}, new String[] {"plain text content"});
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
// Drop the identity-stub invocation recorded during setUp.
|
||||
Mockito.clearInvocations(sanitizer);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
Mockito.verifyNoInteractions(sanitizer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - invalid input")
|
||||
class InvalidInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an unsupported extension throws before any process is started")
|
||||
void unsupportedExtension() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"data".getBytes(StandardCharsets.UTF_8),
|
||||
"document.txt",
|
||||
tempFileManager,
|
||||
sanitizer))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sanitizeZipFilename additional branches")
|
||||
class SanitizeZipFilenameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a bare relative name is returned unchanged")
|
||||
void plainName() {
|
||||
assertThat(FileToPdf.sanitizeZipFilename("file.html")).isEqualTo("file.html");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only the .. sequences are stripped, the rest of the path survives")
|
||||
void stripsTraversalKeepsTail() {
|
||||
String result = FileToPdf.sanitizeZipFilename("a/../b/c.html");
|
||||
assertThat(result).doesNotContain("..").endsWith("c.html");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("repacked ZIP integrity")
|
||||
class RepackedZipTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the temp input zip handed to weasyprint still contains the html entry")
|
||||
void repackedZipContainsEntry() throws Exception {
|
||||
byte[] zip = buildZip(new String[] {"a.html"}, new String[] {"<i>hi</i>"});
|
||||
|
||||
// Inspect the repacked zip from inside the command answer, while the temp file is
|
||||
// still on disk (it is auto-deleted once convertHtmlToPdf returns).
|
||||
List<String> entryNames = new java.util.ArrayList<>();
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doAnswer(
|
||||
invocation -> {
|
||||
List<String> command = invocation.getArgument(0);
|
||||
Path inputZip = Path.of(command.get(command.size() - 2));
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(
|
||||
java.nio.file.Files.newInputStream(inputZip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entryNames.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return successResult();
|
||||
})
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(entryNames).anyMatch(name -> name.endsWith("a.html"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
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.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
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.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Gap coverage for {@link FormUtils} methods not exercised by {@code FormUtilsTest} (disabled) or
|
||||
* {@code FormUtilsAdditionalTest}. Focuses on coordinate extraction, the page-map / repair / prune
|
||||
* / delete / modify lifecycle, and the package-private parsing helpers.
|
||||
*/
|
||||
class FormUtilsGapTest {
|
||||
|
||||
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
|
||||
|
||||
private static SetupDocument createBasicDocument(PDDocument document) {
|
||||
PDPage page = new PDPage();
|
||||
document.addPage(page);
|
||||
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
// Register a Helvetica font in the default resources and set a default appearance so
|
||||
// PDFBox can write text-field values without throwing "/DA is a required entry".
|
||||
PDResources dr = new PDResources();
|
||||
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
acroForm.setDefaultResources(dr);
|
||||
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
acroForm.setNeedAppearances(true);
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
return new SetupDocument(page, acroForm);
|
||||
}
|
||||
|
||||
private static void attachWidget(
|
||||
SetupDocument setup, PDTerminalField field, PDRectangle rectangle) throws IOException {
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(rectangle);
|
||||
widget.setPage(setup.page());
|
||||
// Start from an empty list: a fresh terminal field has no /Kids, so getWidgets() would
|
||||
// return a synthetic widget wrapping the field dict itself. Re-adding that turns the field
|
||||
// into a self-referential non-terminal field whose getWidgets() is empty.
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>();
|
||||
widgets.add(widget);
|
||||
field.setWidgets(widgets);
|
||||
setup.acroForm().getFields().add(field);
|
||||
setup.page().getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Constants
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Field type constants")
|
||||
class Constants {
|
||||
|
||||
@Test
|
||||
void typeConstantsHaveExpectedValues() {
|
||||
assertEquals("text", FormUtils.FIELD_TYPE_TEXT);
|
||||
assertEquals("checkbox", FormUtils.FIELD_TYPE_CHECKBOX);
|
||||
assertEquals("combobox", FormUtils.FIELD_TYPE_COMBOBOX);
|
||||
assertEquals("listbox", FormUtils.FIELD_TYPE_LISTBOX);
|
||||
assertEquals("radio", FormUtils.FIELD_TYPE_RADIO);
|
||||
assertEquals("button", FormUtils.FIELD_TYPE_BUTTON);
|
||||
assertEquals("signature", FormUtils.FIELD_TYPE_SIGNATURE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void choiceFieldTypesContainsExpectedMembers() {
|
||||
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("combobox"));
|
||||
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("listbox"));
|
||||
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("radio"));
|
||||
assertFalse(FormUtils.CHOICE_FIELD_TYPES.contains("text"));
|
||||
assertEquals(3, FormUtils.CHOICE_FIELD_TYPES.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// detectFieldType (choice/radio/signature/button branches)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectFieldType")
|
||||
class DetectFieldType {
|
||||
|
||||
@Test
|
||||
void comboBoxDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals(
|
||||
"combobox", FormUtils.detectFieldType(new PDComboBox(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listBoxDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals("listbox", FormUtils.detectFieldType(new PDListBox(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void radioButtonDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals(
|
||||
"radio", FormUtils.detectFieldType(new PDRadioButton(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals(
|
||||
"signature",
|
||||
FormUtils.detectFieldType(new PDSignatureField(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// isChecked
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("isChecked")
|
||||
class IsChecked {
|
||||
|
||||
@Test
|
||||
void nullIsFalse() {
|
||||
assertFalse(FormUtils.isChecked(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truthyValuesAreChecked() {
|
||||
assertTrue(FormUtils.isChecked("true"));
|
||||
assertTrue(FormUtils.isChecked("1"));
|
||||
assertTrue(FormUtils.isChecked("yes"));
|
||||
assertTrue(FormUtils.isChecked("on"));
|
||||
assertTrue(FormUtils.isChecked("checked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truthyValuesAreCaseInsensitiveAndTrimmed() {
|
||||
assertTrue(FormUtils.isChecked(" TRUE "));
|
||||
assertTrue(FormUtils.isChecked("Yes"));
|
||||
assertTrue(FormUtils.isChecked("ON"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void falsyValuesAreNotChecked() {
|
||||
assertFalse(FormUtils.isChecked("false"));
|
||||
assertFalse(FormUtils.isChecked("0"));
|
||||
assertFalse(FormUtils.isChecked("off"));
|
||||
assertFalse(FormUtils.isChecked(""));
|
||||
assertFalse(FormUtils.isChecked("anything"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// safeValue
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void safeValueEmptyStringPassesThrough() {
|
||||
assertEquals("", FormUtils.safeValue(""));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// parseMultiChoiceSelections
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseMultiChoiceSelections")
|
||||
class ParseMultiChoiceSelections {
|
||||
|
||||
@Test
|
||||
void nullReturnsEmpty() {
|
||||
assertTrue(FormUtils.parseMultiChoiceSelections(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankReturnsEmpty() {
|
||||
assertTrue(FormUtils.parseMultiChoiceSelections(" ").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAndTrims() {
|
||||
List<String> result = FormUtils.parseMultiChoiceSelections(" a , b ,c ");
|
||||
assertEquals(List.of("a", "b", "c"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsEmptySegments() {
|
||||
List<String> result = FormUtils.parseMultiChoiceSelections("a,,b,");
|
||||
assertEquals(List.of("a", "b"), result);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// filterChoiceSelections
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("filterChoiceSelections")
|
||||
class FilterChoiceSelections {
|
||||
|
||||
@Test
|
||||
void nullSelectionsReturnsEmpty() {
|
||||
assertTrue(FormUtils.filterChoiceSelections(null, List.of("A"), "f").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySelectionsReturnsEmpty() {
|
||||
assertTrue(FormUtils.filterChoiceSelections(List.of(), List.of("A"), "f").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectionsOfOnlyBlanksReturnsEmpty() {
|
||||
List<String> selections = new ArrayList<>();
|
||||
selections.add(" ");
|
||||
selections.add(null);
|
||||
assertTrue(FormUtils.filterChoiceSelections(selections, List.of("A"), "f").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingSelectionsAreKeptCaseInsensitively() {
|
||||
List<String> result =
|
||||
FormUtils.filterChoiceSelections(
|
||||
List.of("apple", "BANANA"), List.of("Apple", "Banana", "Cherry"), "f");
|
||||
// The resolved (canonical) allowed option is returned, not the input.
|
||||
assertEquals(List.of("Apple", "Banana"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedSelectionsAreDropped() {
|
||||
List<String> result =
|
||||
FormUtils.filterChoiceSelections(
|
||||
List.of("Apple", "Grape"), List.of("Apple", "Banana"), "f");
|
||||
assertEquals(List.of("Apple"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAllowedOptionsThrows() {
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> FormUtils.filterChoiceSelections(List.of("Apple"), List.of(), "fieldX"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAllowedOptionsThrows() {
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> FormUtils.filterChoiceSelections(List.of("Apple"), null, "fieldX"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// resolveOptions / resolveDisplayOptions / collectChoiceAllowedValues
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("option resolution")
|
||||
class OptionResolution {
|
||||
|
||||
@Test
|
||||
void resolveOptionsForComboBox() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setOptions(List.of("Red", "Green", "Blue"));
|
||||
List<String> options = FormUtils.resolveOptions(combo);
|
||||
assertTrue(options.contains("Red"));
|
||||
assertTrue(options.contains("Green"));
|
||||
assertTrue(options.contains("Blue"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveOptionsForTextFieldIsEmpty() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
assertTrue(FormUtils.resolveOptions(text).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveOptionsForCheckBoxUsesExportValues() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDCheckBox checkBox = new PDCheckBox(setup.acroForm());
|
||||
checkBox.setExportValues(List.of("Yes"));
|
||||
assertEquals(List.of("Yes"), FormUtils.resolveOptions(checkBox));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveDisplayOptionsEmptyForTextField() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
assertTrue(FormUtils.resolveDisplayOptions(text).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectChoiceAllowedValuesNullReturnsEmpty() {
|
||||
assertTrue(FormUtils.collectChoiceAllowedValues(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectChoiceAllowedValuesReturnsOptions() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setOptions(List.of("One", "Two"));
|
||||
List<String> allowed = FormUtils.collectChoiceAllowedValues(combo);
|
||||
assertTrue(allowed.contains("One"));
|
||||
assertTrue(allowed.contains("Two"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// setTextValue
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("setTextValue")
|
||||
class SetTextValue {
|
||||
|
||||
@Test
|
||||
void writesValue() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("note");
|
||||
text.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(20, 600, 200, 20));
|
||||
|
||||
FormUtils.setTextValue(text, "hello world");
|
||||
assertEquals("hello world", text.getValueAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullValueWritesEmptyString() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("note");
|
||||
text.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(20, 600, 200, 20));
|
||||
|
||||
FormUtils.setTextValue(text, null);
|
||||
assertEquals("", text.getValueAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// buildFillTemplateRecord (choice branches not covered elsewhere)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildFillTemplateRecord")
|
||||
class BuildFillTemplateRecord {
|
||||
|
||||
@Test
|
||||
void comboBoxUsesCurrentValue() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"color", "Color", "combobox", "Red", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("Red", result.get("color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleSelectListBoxUsesValue() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"list", "List", "listbox", "Item1", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("Item1", result.get("list"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSelectListBoxUsesEmptyArray() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"list", "List", "listbox", "Item1", null, false, 0, true, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
Object value = result.get("list");
|
||||
assertTrue(value instanceof List<?>);
|
||||
assertTrue(((List<?>) value).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullValueDefaultsToEmptyString() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"name", "Name", "text", null, null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("", result.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void entriesWithBlankNamesAreSkipped() {
|
||||
FormUtils.FormFieldInfo blank =
|
||||
new FormUtils.FormFieldInfo(
|
||||
" ", "Blank", "text", "x", null, false, 0, false, null, 0);
|
||||
FormUtils.FormFieldInfo good =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"kept", "Kept", "text", "x", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(blank, good));
|
||||
assertEquals(1, result.size());
|
||||
assertTrue(result.containsKey("kept"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// buildAnnotationPageMap
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildAnnotationPageMap")
|
||||
class BuildAnnotationPageMap {
|
||||
|
||||
@Test
|
||||
void nullDocumentReturnsEmpty() {
|
||||
assertTrue(FormUtils.buildAnnotationPageMap(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyDocumentReturnsEmpty() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertTrue(FormUtils.buildAnnotationPageMap(doc).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsWidgetToItsPageIndex() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("a");
|
||||
attachWidget(setup, text, new PDRectangle(10, 10, 100, 20));
|
||||
|
||||
Map<COSDictionary, Integer> map = FormUtils.buildAnnotationPageMap(doc);
|
||||
assertEquals(1, map.size());
|
||||
assertTrue(map.containsValue(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// extractFormFieldsWithCoordinates
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractFormFieldsWithCoordinates")
|
||||
class ExtractFormFieldsWithCoordinates {
|
||||
|
||||
@Test
|
||||
void nullDocumentReturnsEmpty() {
|
||||
assertTrue(FormUtils.extractFormFieldsWithCoordinates(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAcroFormReturnsEmpty() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertTrue(FormUtils.extractFormFieldsWithCoordinates(doc).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void textFieldProducesWidgetCoordinates() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("firstName");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<stirling.software.common.model.FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(1, fields.size());
|
||||
stirling.software.common.model.FormFieldWithCoordinates field = fields.get(0);
|
||||
assertEquals("firstName", field.getName());
|
||||
assertEquals("text", field.getType());
|
||||
assertNotNull(field.getWidgets());
|
||||
assertEquals(1, field.getWidgets().size());
|
||||
stirling.software.common.model.FormFieldWithCoordinates.WidgetCoordinates wc =
|
||||
field.getWidgets().get(0);
|
||||
assertEquals(0, wc.getPageIndex());
|
||||
// x is relative to crop-box origin (0 here), so it equals the lower-left x.
|
||||
assertEquals(50f, wc.getX(), 0.01f);
|
||||
assertEquals(200f, wc.getWidth(), 0.01f);
|
||||
assertEquals(20f, wc.getHeight(), 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleFieldsAreSortedTopToBottom() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
|
||||
PDTextField lower = new PDTextField(setup.acroForm());
|
||||
lower.setPartialName("lower");
|
||||
attachWidget(setup, lower, new PDRectangle(50, 100, 200, 20));
|
||||
|
||||
PDTextField upper = new PDTextField(setup.acroForm());
|
||||
upper.setPartialName("upper");
|
||||
attachWidget(setup, upper, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<stirling.software.common.model.FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(2, fields.size());
|
||||
// The widget higher on the page (smaller CSS-y after flip) sorts first.
|
||||
assertEquals("upper", fields.get(0).getName());
|
||||
assertEquals("lower", fields.get(1).getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// repairMissingWidgetPageReferences
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("repairMissingWidgetPageReferences")
|
||||
class RepairMissingWidgetPageReferences {
|
||||
|
||||
@Test
|
||||
void nullDocumentDoesNotThrow() {
|
||||
FormUtils.repairMissingWidgetPageReferences(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentWithoutAcroFormDoesNotThrow() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
FormUtils.repairMissingWidgetPageReferences(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void setsPageReferenceForOrphanWidget() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("orphan");
|
||||
|
||||
// Build a widget that is on the page's annotation list but has no /P page ref.
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(10, 10, 100, 20));
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>(text.getWidgets());
|
||||
widgets.add(widget);
|
||||
text.setWidgets(widgets);
|
||||
setup.acroForm().getFields().add(text);
|
||||
setup.page().getAnnotations().add(widget);
|
||||
|
||||
assertNull(widget.getPage());
|
||||
FormUtils.repairMissingWidgetPageReferences(doc);
|
||||
assertNotNull(widget.getPage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// deleteFormFields
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteFormFields")
|
||||
class DeleteFormFields {
|
||||
|
||||
@Test
|
||||
void nullDocumentIsNoOp() {
|
||||
FormUtils.deleteFormFields(null, List.of("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullNamesIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.deleteFormFields(doc, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyNamesIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.deleteFormFields(doc, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesNamedField() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField keep = new PDTextField(setup.acroForm());
|
||||
keep.setPartialName("keep");
|
||||
attachWidget(setup, keep, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
PDTextField remove = new PDTextField(setup.acroForm());
|
||||
remove.setPartialName("remove");
|
||||
attachWidget(setup, remove, new PDRectangle(50, 660, 200, 20));
|
||||
|
||||
FormUtils.deleteFormFields(doc, List.of("remove"));
|
||||
|
||||
List<FormUtils.FormFieldInfo> remaining = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, remaining.size());
|
||||
assertEquals("keep", remaining.get(0).name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownFieldNameIsIgnored() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField keep = new PDTextField(setup.acroForm());
|
||||
keep.setPartialName("keep");
|
||||
attachWidget(setup, keep, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.deleteFormFields(doc, List.of("doesNotExist", " ", "keep"));
|
||||
assertTrue(FormUtils.extractFormFields(doc).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// modifyFormFields
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("modifyFormFields")
|
||||
class ModifyFormFields {
|
||||
|
||||
@Test
|
||||
void nullDocumentIsNoOp() {
|
||||
FormUtils.modifyFormFields(null, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullModificationsIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.modifyFormFields(doc, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyModificationsIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.modifyFormFields(doc, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void inPlaceRenameAndLabelUpdate() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("oldName");
|
||||
text.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"oldName",
|
||||
"newName",
|
||||
"New Label",
|
||||
null, // keep type (text) -> in-place path
|
||||
Boolean.TRUE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("newName", fields.get(0).name());
|
||||
assertEquals("New Label", fields.get(0).label());
|
||||
assertTrue(fields.get(0).required());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTargetIsSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("present");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"missing", null, null, null, null, null, null, null, null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
// Untouched field remains.
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("present", fields.get(0).name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullEntriesAndBlankTargetsAreSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("present");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.ModifyFormFieldDefinition> mods = new ArrayList<>();
|
||||
mods.add(null);
|
||||
mods.add(
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
" ", null, null, null, null, null, null, null, null));
|
||||
|
||||
FormUtils.modifyFormFields(doc, mods);
|
||||
assertEquals(1, FormUtils.extractFormFields(doc).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// pruneOrphanedFormFields
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("pruneOrphanedFormFields")
|
||||
class PruneOrphanedFormFields {
|
||||
|
||||
@Test
|
||||
void nullDocumentIsNoOp() {
|
||||
FormUtils.pruneOrphanedFormFields(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentWithoutAcroFormIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
assertNull(doc.getDocumentCatalog().getAcroForm(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsFieldsWithLiveWidgets() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("live");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
|
||||
PDAcroForm form = doc.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(form);
|
||||
assertEquals(1, form.getFields().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsAcroFormWhenAllWidgetsOrphaned() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("orphan");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
// Remove the widget from the page so it is no longer "live".
|
||||
setup.page().getAnnotations().clear();
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
|
||||
assertNull(doc.getDocumentCatalog().getAcroForm(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// hasAnyRotatedPage (rotated branch)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("hasAnyRotatedPage")
|
||||
class HasAnyRotatedPage {
|
||||
|
||||
@Test
|
||||
void rotatedPageDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
page.setRotation(90);
|
||||
doc.addPage(page);
|
||||
assertTrue(FormUtils.hasAnyRotatedPage(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrotatedPageNotDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertFalse(FormUtils.hasAnyRotatedPage(doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user