mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e556eba832 | ||
|
|
4ea39559c0 | ||
|
|
00d6a6e071 | ||
|
|
cc22deda3f | ||
|
|
1ed7338bb9 | ||
|
|
5dc4ba0204 | ||
|
|
ad95a046ed | ||
|
|
2d0d4443f8 | ||
|
|
656a0ae268 | ||
|
|
f1ee0bdbab | ||
|
|
abc9a08566 | ||
|
|
ab51bcf0f0 | ||
|
|
0ba8b9fcc1 | ||
|
|
e43af565ab | ||
|
|
58326adee4 | ||
|
|
fd9f52d756 | ||
|
|
91f4376371 | ||
|
|
29df488b4e | ||
|
|
e3ff34efd1 | ||
|
|
38ccea074c | ||
|
|
a7307ff393 | ||
|
|
72729e99c1 | ||
|
|
5fba2720f0 | ||
|
|
01a1ef8c44 | ||
|
|
8535c7e9ac | ||
|
|
cca3f42623 | ||
|
|
1b7ffcdbac | ||
|
|
67a0ca6110 | ||
|
|
8e4b2e2fc6 | ||
|
|
3c93457021 | ||
|
|
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 "=================================================================="
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.12.0
|
||||
pkgver=2.14.3
|
||||
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.3
|
||||
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,8 @@ engine: &engine
|
||||
- engine/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
@@ -102,4 +114,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
|
||||
@@ -23,13 +23,9 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-pr:
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
@@ -101,8 +97,8 @@ jobs:
|
||||
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: [pick, check-pr]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
@@ -112,10 +108,10 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Single source of truth for whether this preview embeds the admin portal:
|
||||
# drives the image build-arg and the deployment comment.
|
||||
BUILD_PORTAL: "true"
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -187,12 +183,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get version number
|
||||
@@ -237,21 +228,9 @@ jobs:
|
||||
echo "Image needs to be built"
|
||||
fi
|
||||
|
||||
- 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
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -259,7 +238,9 @@ jobs:
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -290,10 +271,11 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
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 }}"
|
||||
@@ -333,9 +315,70 @@ jobs:
|
||||
# Set port for output
|
||||
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
|
||||
|
||||
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
|
||||
# Runs inside the same approved-contributor-gated deploy job, so it deploys
|
||||
# under the exact same access rules as the app preview.
|
||||
- name: Detect Storybook changes
|
||||
id: sb-changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
with:
|
||||
list-files: json
|
||||
filters: |
|
||||
storybook:
|
||||
- 'frontend/**/*.stories.@(ts|tsx|mdx)'
|
||||
- 'frontend/**/*.mdx'
|
||||
- 'frontend/.storybook/**'
|
||||
|
||||
- name: Set up Node.js for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build and deploy Storybook
|
||||
id: storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `prepare` generates the icon set stories import (not committed).
|
||||
task frontend:prepare
|
||||
task frontend:storybook:build
|
||||
PR=${{ needs.check-pr.outputs.pr_number }}
|
||||
# Served at the ROOT of its own port so Storybook's global MSW worker
|
||||
# (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset
|
||||
# from the app preview's bare-PR-number port).
|
||||
SB_PORT=$((PR + 20000))
|
||||
DIR=/stirling/SB-PR-$PR
|
||||
tar czf storybook.tgz -C frontend/storybook-static .
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz"
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \
|
||||
"$VPS_USER@$VPS_HOST" << ENDSSH
|
||||
set -e
|
||||
rm -rf "$DIR" && mkdir -p "$DIR"
|
||||
tar xzf /tmp/storybook-$PR.tgz -C "$DIR"
|
||||
rm -f /tmp/storybook-$PR.tgz
|
||||
docker rm -f storybook-pr-$PR 2>/dev/null || true
|
||||
docker run -d --name storybook-pr-$PR --restart unless-stopped \
|
||||
-p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine
|
||||
ENDSSH
|
||||
echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
SB_URL: ${{ steps.storybook.outputs.url }}
|
||||
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -359,12 +402,40 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
// Only mention the portal when this image actually embeds it.
|
||||
// Use the direct IP URL - the SSL hostname isn't supported yet.
|
||||
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
|
||||
const portalNote = withPortal
|
||||
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
|
||||
: ``;
|
||||
|
||||
// Storybook preview: only present when this PR changed stories/config.
|
||||
const sbUrl = process.env.SB_URL;
|
||||
let storybookNote = "";
|
||||
if (sbUrl) {
|
||||
const files = JSON.parse(process.env.SB_FILES || "[]");
|
||||
const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f));
|
||||
const config = files.filter((f) => f.startsWith("frontend/.storybook/"));
|
||||
const shorten = (f) =>
|
||||
f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, "");
|
||||
const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n");
|
||||
const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n");
|
||||
const summary =
|
||||
`${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` +
|
||||
(config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : "");
|
||||
storybookNote =
|
||||
`📚 **Storybook:** [${sbUrl}](${sbUrl})\n\n` +
|
||||
`<details>\n<summary>${summary}</summary>\n\n` +
|
||||
(storyList ? `**Stories**\n${storyList}\n\n` : "") +
|
||||
(configList ? `**Config**\n${configList}\n` : "") +
|
||||
`</details>\n\n`;
|
||||
}
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with embedded architecture has been deployed!\n\n` +
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
portalNote +
|
||||
storybookNote +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
@@ -377,8 +448,7 @@ jobs:
|
||||
|
||||
cleanup-v2-deployment:
|
||||
if: github.event.action == 'closed'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
@@ -460,7 +530,11 @@ jobs:
|
||||
else
|
||||
echo "V2 PR directory not found, nothing to clean up"
|
||||
fi
|
||||
|
||||
|
||||
# Remove this PR's Storybook preview (container + files), if any.
|
||||
docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true
|
||||
rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }}
|
||||
|
||||
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
|
||||
|
||||
@@ -34,12 +34,8 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-comment:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
if: |
|
||||
@@ -179,15 +175,11 @@ jobs:
|
||||
}
|
||||
|
||||
deploy-pr:
|
||||
needs: [pick, check-comment]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: check-comment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -220,12 +212,12 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- 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
|
||||
@@ -240,12 +232,7 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
@@ -254,23 +241,8 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push PR-specific image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: |
|
||||
VERSION_TAG=alpha
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push PR-specific image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push PR-specific image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -283,20 +255,9 @@ jobs:
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- 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
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push engine image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push engine image
|
||||
if: needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
@@ -388,7 +349,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}"
|
||||
@@ -510,8 +471,7 @@ jobs:
|
||||
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -2,8 +2,8 @@ name: _runner-pick
|
||||
|
||||
# Tiny reusable workflow that classifies the trigger as either a "fork PR
|
||||
# from an untrusted contributor" or a "trusted commit" so downstream jobs
|
||||
# can pick a runner class without each one duplicating the 200-char gate
|
||||
# expression in their own `runs-on:`.
|
||||
# can trust-gate (skip secret-dependent jobs on forks) without each one
|
||||
# duplicating the gate expression.
|
||||
#
|
||||
# Caller pattern:
|
||||
#
|
||||
@@ -13,12 +13,12 @@ name: _runner-pick
|
||||
#
|
||||
# real-work:
|
||||
# needs: pick
|
||||
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
# if: needs.pick.outputs.is_fork != 'true'
|
||||
# steps: [...]
|
||||
#
|
||||
# Output:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise.
|
||||
# Outputs:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -50,21 +50,18 @@ jobs:
|
||||
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
# Not a pull_request event at all (push, schedule, workflow_dispatch,
|
||||
# workflow_call from a non-PR trigger) -> trusted by default.
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
is_fork=false
|
||||
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
|
||||
is_fork=true
|
||||
else
|
||||
case "${AUTHOR_ASSOC}" in
|
||||
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
|
||||
*) is_fork=true ;;
|
||||
esac
|
||||
fi
|
||||
if [ "${HEAD_REPO_FORK}" = "true" ]; then
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
case "${AUTHOR_ASSOC}" in
|
||||
OWNER|MEMBER|COLLABORATOR)
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -18,8 +18,6 @@ jobs:
|
||||
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
|
||||
@@ -30,7 +28,7 @@ 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
|
||||
|
||||
@@ -43,10 +41,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: Regenerate tool models
|
||||
run: task engine:tool-models
|
||||
|
||||
@@ -19,14 +19,8 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -47,7 +41,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -56,13 +50,13 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ 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
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
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,28 +12,14 @@ 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:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
# No `concurrency:` block here on purpose. When this workflow is called via
|
||||
# workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number
|
||||
@@ -52,13 +38,16 @@ jobs:
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
|
||||
# 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: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -78,7 +67,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 +154,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 +229,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 +261,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 +275,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
|
||||
|
||||
@@ -147,7 +147,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/test-build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -11,8 +11,6 @@ permissions:
|
||||
jobs:
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -40,11 +38,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:
|
||||
|
||||
@@ -10,14 +10,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-generate-openapi-docs:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -34,7 +28,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -43,13 +37,13 @@ jobs:
|
||||
key: gradle-deps-${{ 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
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
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,17 +13,24 @@ 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
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
aggregate:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -40,7 +47,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -49,9 +56,9 @@ jobs:
|
||||
key: gradle-deps-${{ 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
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Python
|
||||
@@ -196,9 +203,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 +213,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
|
||||
|
||||
|
||||
@@ -12,15 +12,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
migration-test:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -37,7 +31,7 @@ jobs:
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -46,9 +40,9 @@ jobs:
|
||||
key: gradle-deps-${{ 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
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
|
||||
@@ -10,21 +10,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
deploy-v2-on-push:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-v2-push-V2
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -35,12 +25,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
@@ -105,23 +90,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- 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
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push frontend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
@@ -134,23 +105,9 @@ jobs:
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- 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
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
@@ -188,7 +145,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
|
||||
@@ -11,28 +11,17 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 4 because bench showed 16 was within noise of 4."
|
||||
required: false
|
||||
type: string
|
||||
default: "4"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
docker-compose-tests:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -50,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -59,16 +48,24 @@ jobs:
|
||||
key: gradle-deps-${{ 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
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
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
|
||||
|
||||
@@ -5,23 +5,13 @@ name: Playwright E2E (live backend)
|
||||
# server.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e-live:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -42,7 +32,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 +52,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 +169,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 +188,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
|
||||
|
||||
@@ -5,23 +5,13 @@ name: Playwright E2E (stubbed)
|
||||
# mocks API responses in the browser.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -36,7 +26,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 +34,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
|
||||
|
||||
@@ -19,13 +19,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
@@ -48,8 +44,8 @@ jobs:
|
||||
generate-frontend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: [pick, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -97,7 +93,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:
|
||||
@@ -292,7 +295,10 @@ jobs:
|
||||
base: main
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
labels: |
|
||||
Licenses
|
||||
github-actions
|
||||
Front End
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
@@ -311,15 +317,13 @@ jobs:
|
||||
|
||||
generate-backend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: [pick, files-changed]
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -347,12 +351,13 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- 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
|
||||
@@ -512,7 +517,10 @@ jobs:
|
||||
base: main
|
||||
title: "Update Backend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,backend
|
||||
labels: |
|
||||
Licenses
|
||||
github-actions
|
||||
Back End
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
|
||||
@@ -11,12 +11,8 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
frontend-validation:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -31,7 +27,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
|
||||
|
||||
@@ -12,13 +12,14 @@ on:
|
||||
- "true"
|
||||
- "false"
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
description: "Platform to build (windows, windows-arm64, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- windows-arm64
|
||||
- macos
|
||||
- linux
|
||||
sign:
|
||||
@@ -36,13 +37,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
@@ -61,7 +58,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -71,12 +68,12 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- 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: |
|
||||
@@ -91,31 +88,40 @@ jobs:
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
# windows-arm64: NSIS only (WiX MSI has no arm64 support in Tauri) and no
|
||||
# JPDFium natives yet - flip to windows-arm64 once JPDFium ships them.
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
|
||||
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"windows-arm64")
|
||||
echo "matrix={\"include\":[$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$MACOS]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$LINUX]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
needs: [pick, determine-matrix]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
@@ -146,9 +152,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -159,7 +165,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
|
||||
@@ -195,7 +201,6 @@ jobs:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -243,19 +248,20 @@ jobs:
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- 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.
|
||||
@@ -278,7 +284,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -288,7 +294,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -323,7 +329,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.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -391,7 +397,7 @@ jobs:
|
||||
# Without this, signCommand failures are opaque (Tauri captures but drops
|
||||
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
|
||||
- name: Preflight smctl
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -422,7 +428,7 @@ jobs:
|
||||
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
|
||||
# from env (set by prior DigiCert setup step). No --config-file needed.
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -493,6 +499,54 @@ jobs:
|
||||
args: ${{ matrix.args }}
|
||||
updaterJsonKeepUniversal: true
|
||||
|
||||
# Bundled libwayland conflicts with the host's on some distros (Fedora
|
||||
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
|
||||
# then regenerate the updater .sig (repack invalidates the original) and
|
||||
# GPG-sign again when release signing is on.
|
||||
- name: Strip bundled Wayland libs from AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
|
||||
chmod +x "$AI"
|
||||
WORK=$(mktemp -d)
|
||||
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
|
||||
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
|
||||
echo "No bundled libwayland - nothing to strip"
|
||||
rm -rf "$WORK"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
|
||||
curl -fsSL -o "$WORK/appimagetool" \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
# Pinned checksum: never execute an unverified downloaded binary. On
|
||||
# mismatch (upstream rebuilt continuous) the step aborts and the
|
||||
# original AppImage ships unchanged - update the pin deliberately.
|
||||
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
|
||||
chmod +x "$WORK/appimagetool"
|
||||
SIGN_ARGS=()
|
||||
if [ "$GPG_SIGN" = "1" ] && [ -n "${SIGN_KEY:-}" ]; then
|
||||
SIGN_ARGS=(--sign --sign-key "$SIGN_KEY")
|
||||
fi
|
||||
"$WORK/appimagetool" --appimage-extract-and-run "${SIGN_ARGS[@]}" "$WORK/squashfs-root" "$AI.new"
|
||||
# Updater payload signature must match the repacked bytes. The CLI
|
||||
# reads the key/password from env - never pass secrets as argv.
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
(cd frontend && npx tauri signer sign "$AI.new")
|
||||
mv "$AI.new.sig" "$AI.sig"
|
||||
fi
|
||||
mv "$AI.new" "$AI"
|
||||
rm -rf "$WORK"
|
||||
echo "Stripped bundled libwayland from $(basename "$AI")"
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
@@ -509,9 +563,31 @@ jobs:
|
||||
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
|
||||
# inner exe before packing and the setup exe after, so verifying the setup
|
||||
# exe is the arm64 equivalent of the MSI + inner-exe check below.
|
||||
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
|
||||
$setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
|
||||
if ($setupExes.Count -eq 0) {
|
||||
Write-Host "[ERROR] No NSIS installer found under target/"
|
||||
exit 1
|
||||
}
|
||||
foreach ($exe in $setupExes) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exe.FullName
|
||||
Write-Host "NSIS installer: $($exe.Name) Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
|
||||
if ($sig.Status -ne "Valid") {
|
||||
Write-Host "[ERROR] NSIS installer is not signed"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Write-Host "[SUCCESS] NSIS installer is properly signed"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$allSigned = $true
|
||||
|
||||
# Check MSI installer (outer wrapper - what users download)
|
||||
@@ -531,11 +607,26 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -548,9 +639,6 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -563,7 +651,7 @@ jobs:
|
||||
# but drops stderr when the command exits non-zero, making failures opaque.
|
||||
# The real errors live in smctl's log files - surface them here for debugging.
|
||||
- name: Dump smctl logs on failure
|
||||
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logDir = "$env:USERPROFILE\.signingmanager\logs"
|
||||
@@ -599,6 +687,11 @@ jobs:
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
|
||||
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
|
||||
# The setup exe is also its own updater payload (-> sibling .sig).
|
||||
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
|
||||
find . -name "*-setup.exe.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe.sig" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
# DMG = manual install; .app.tar.gz (+ .sig) = updater payload.
|
||||
# Raw .app is intentionally not shipped (hundreds of MB of uncompressed input).
|
||||
@@ -625,8 +718,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
collect-and-release:
|
||||
needs: [pick, determine-matrix, build, build-jars]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: [determine-matrix, build, build-jars]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -713,6 +806,10 @@ jobs:
|
||||
'bundles': ['Stirling-PDF-windows-x86_64.msi'],
|
||||
'targets': ['windows-x86_64-msi', 'windows-x86_64'],
|
||||
},
|
||||
{
|
||||
'bundles': ['Stirling-PDF-windows-arm64-setup.exe'],
|
||||
'targets': ['windows-aarch64-nsis', 'windows-aarch64'],
|
||||
},
|
||||
{
|
||||
'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'],
|
||||
'targets': ['darwin-x86_64', 'darwin-aarch64'],
|
||||
@@ -800,13 +897,18 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
files: |
|
||||
./artifacts/**/*.jar
|
||||
./artifacts/**/*.msi
|
||||
./artifacts/**/*-setup.exe
|
||||
./artifacts/**/*.dmg
|
||||
./artifacts/**/*.app.tar.gz
|
||||
./artifacts/**/*.deb
|
||||
|
||||
@@ -13,13 +13,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-all-browsers:
|
||||
name: Playwright (chromium + firefox + webkit)
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -37,10 +33,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 +49,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
|
||||
|
||||
@@ -22,15 +22,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -46,9 +40,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
@@ -63,7 +57,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
|
||||
|
||||
@@ -12,22 +12,44 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)."
|
||||
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)."
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
minimal:
|
||||
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- windows-arm64
|
||||
- macos
|
||||
- linux
|
||||
- windows-macos
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
minimal:
|
||||
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -41,7 +63,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -52,14 +74,19 @@ jobs:
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
|
||||
# ARM64: NSIS only (WiX MSI has no arm64 support in Tauri) and no JPDFium
|
||||
# natives yet - flip jpdfium_platforms to windows-arm64 once JPDFium ships it.
|
||||
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
windows) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64") ;;
|
||||
windows-arm64) ENTRIES=("$WINDOWS_ARM64") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -86,10 +113,14 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Per-platform sign gate. macOS signs on any run with the cert available,
|
||||
# PRs included: Gatekeeper blocks an unsigned .dmg, so an unsigned macOS
|
||||
# PR build is not testable. Windows and Linux stay main-only, matching the
|
||||
# gates on their own signing steps below.
|
||||
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -103,7 +134,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -115,6 +146,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
|
||||
@@ -127,19 +172,20 @@ jobs:
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- 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 +209,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 && startsWith(matrix.platform, 'windows') && 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 +219,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 && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -208,7 +254,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 && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -239,7 +285,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -260,7 +306,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -283,7 +329,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 && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -296,7 +342,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 && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -315,7 +361,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 +378,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
- name: Build Tauri app (signed)
|
||||
if: env.SIGN_BUNDLE == 'true'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -361,20 +408,43 @@ jobs:
|
||||
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 }}
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: env.SIGN_BUNDLE != 'true'
|
||||
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 (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
|
||||
args: >-
|
||||
${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
|
||||
--config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
# AppImage is decoupled so its linuxdeploy run gets a fresh process
|
||||
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
|
||||
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
|
||||
continue-on-error: true
|
||||
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 }}
|
||||
@@ -388,8 +458,39 @@ jobs:
|
||||
tauriScript: npx tauri
|
||||
args: --bundles appimage
|
||||
|
||||
# Bundled libwayland conflicts with the host's on some distros (Fedora
|
||||
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). The AppImage
|
||||
# ecosystem excludelist agrees these libs must come from the system.
|
||||
- name: Strip bundled Wayland libs from AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
|
||||
chmod +x "$AI"
|
||||
WORK=$(mktemp -d)
|
||||
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
|
||||
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
|
||||
echo "No bundled libwayland - nothing to strip"
|
||||
rm -rf "$WORK"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
|
||||
curl -fsSL -o "$WORK/appimagetool" \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
# Pinned checksum: never execute an unverified downloaded binary. On
|
||||
# mismatch (upstream rebuilt continuous) the step aborts and the
|
||||
# original AppImage ships unchanged - update the pin deliberately.
|
||||
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
|
||||
chmod +x "$WORK/appimagetool"
|
||||
"$WORK/appimagetool" --appimage-extract-and-run "$WORK/squashfs-root" "$AI.new"
|
||||
mv "$AI.new" "$AI"
|
||||
rm -rf "$WORK"
|
||||
echo "Stripped bundled libwayland from $(basename "$AI")"
|
||||
|
||||
- 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 +500,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15'
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
@@ -426,6 +527,9 @@ jobs:
|
||||
# Only ship the MSI installer. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
|
||||
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
else
|
||||
@@ -437,9 +541,28 @@ 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 && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
|
||||
# inner exe before packing and the setup exe after, so verifying the setup
|
||||
# exe is the arm64 equivalent of the MSI + inner-exe check below.
|
||||
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
|
||||
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}-setup.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
Write-Host "[ERROR] NSIS installer not found at $exePath"
|
||||
exit 1
|
||||
}
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exePath
|
||||
Write-Host "NSIS installer: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
|
||||
if ($sig.Status -ne "Valid") {
|
||||
Write-Host "[ERROR] NSIS installer is not signed"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[SUCCESS] NSIS installer is properly signed"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$allSigned = $true
|
||||
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
|
||||
|
||||
@@ -485,7 +608,7 @@ jobs:
|
||||
Write-Host "[SUCCESS] MSI and inner exe are properly signed"
|
||||
|
||||
- name: Dump smctl logs on failure
|
||||
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logDir = "$env:USERPROFILE\.signingmanager\logs"
|
||||
@@ -512,7 +635,7 @@ jobs:
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
|
||||
# Check for expected artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
echo "Checking for Windows artifacts..."
|
||||
find . -name "*.exe" -o -name "*.msi" | head -5
|
||||
if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
|
||||
@@ -556,12 +679,19 @@ jobs:
|
||||
pr-comment:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && needs.build.result == 'success'
|
||||
# Fork and Dependabot pull_request runs receive a read-only GITHUB_TOKEN,
|
||||
# so the API cannot create or update PR comments there. The artifacts are
|
||||
# still uploaded and remain available from the Actions run page.
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.build.result == 'success' &&
|
||||
!github.event.pull_request.head.repo.fork &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -584,6 +714,7 @@ jobs:
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-windows-arm64': { icon: '🪟', platform: 'Windows ARM64', files: '-setup.exe (NSIS)' },
|
||||
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
|
||||
};
|
||||
@@ -652,7 +783,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -12,19 +12,16 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
dockerfiles-changed:
|
||||
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
# TODO: extract a pre-matrix `prepare` job that runs once and produces
|
||||
# shared artifacts for the three matrix entries below to consume:
|
||||
# 1. `task backend:build` — currently runs 3× in parallel with
|
||||
@@ -40,14 +37,7 @@ jobs:
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -95,7 +85,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -104,13 +94,13 @@ jobs:
|
||||
key: gradle-deps-${{ 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
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
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:
|
||||
@@ -120,16 +110,10 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -146,32 +130,42 @@ jobs:
|
||||
# GITHUB_EVENT_NAME is already provided by the runner.
|
||||
env:
|
||||
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
|
||||
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
|
||||
# Base Dockerfile changed: build against the locally-built base,
|
||||
# which only exists for amd64.
|
||||
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
|
||||
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# No Dockerfile change: amd64 only. arm64 is exercised on the base
|
||||
# image publish and on release, not on every code PR.
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }} (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
platforms: ${{ steps.build-params.outputs.platforms }}
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
# 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 }} (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
# 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 }}
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -198,14 +192,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
test-build-unoserver-image:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -215,36 +202,15 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
tags: stirling-unoserver:pr-test
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -20,19 +20,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -49,9 +39,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -61,12 +51,7 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get version number
|
||||
@@ -81,21 +66,8 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push test image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push test image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -129,7 +101,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"
|
||||
@@ -153,8 +125,7 @@ jobs:
|
||||
files-changed:
|
||||
if: always()
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
@@ -174,8 +145,8 @@ jobs:
|
||||
|
||||
test:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [pick, deploy, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: [deploy, files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -208,8 +179,8 @@ jobs:
|
||||
FORCE_COLOR: "3"
|
||||
|
||||
cleanup:
|
||||
needs: [pick, deploy, test]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: [deploy, test]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
|
||||
@@ -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/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
|
||||
# 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]
|
||||
|
||||
+49
-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: |
|
||||
@@ -17,6 +22,7 @@ vars:
|
||||
linux-amd64) echo "linux-x64";;
|
||||
linux-arm64) echo "linux-arm64";;
|
||||
windows-amd64) echo "windows-x64";;
|
||||
windows-arm64) echo "none";; # no JPDFium windows-arm64 natives published yet
|
||||
*) echo "all";;
|
||||
esac
|
||||
fi
|
||||
@@ -102,6 +108,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 +147,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:
|
||||
|
||||
+129
-11
@@ -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
|
||||
@@ -114,9 +130,34 @@ tasks:
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
@@ -137,8 +178,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"
|
||||
@@ -165,8 +208,26 @@ tasks:
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- npx vite build portal
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -234,17 +295,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 +322,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 +331,45 @@ 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: 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
|
||||
- task: typecheck:_run
|
||||
vars: { 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: shared/tsconfig.json }
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
@@ -293,6 +378,7 @@ tasks:
|
||||
- task: typecheck:proprietary
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:cloud
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
@@ -310,9 +396,17 @@ 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
|
||||
@@ -327,10 +421,22 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
- task: test:portal
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
|
||||
test:portal:
|
||||
desc: "Run portal tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root portal
|
||||
|
||||
test:watch:
|
||||
desc: "Run tests in watch mode"
|
||||
deps: [prepare]
|
||||
@@ -367,3 +473,15 @@ tasks:
|
||||
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, dist-portal
|
||||
platforms: [windows]
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
|
||||
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
|
||||
|
||||
@@ -152,7 +152,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 +169,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 +450,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
|
||||
|
||||
+12
-1
@@ -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:
|
||||
@@ -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,6 +16,8 @@ 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,
|
||||
|
||||
@@ -60,7 +60,7 @@ For full installation options (including desktop and Kubernetes), see our [Docum
|
||||
|
||||
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).
|
||||
|
||||
|
||||
+122
-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,99 @@ 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 + developer portal concurrently on free ports"
|
||||
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}}'
|
||||
PORTAL_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:portal
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
|
||||
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 +180,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}}'
|
||||
@@ -107,6 +201,24 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
@@ -175,4 +287,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
|
||||
|
||||
+18
-12
@@ -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,27 +60,33 @@ 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>
|
||||
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
|
||||
? jpdfiumAllPlatforms
|
||||
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
|
||||
def jpdfiumPlatforms
|
||||
if (jpdfiumPlatformsProp == 'all') {
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
} else if (jpdfiumPlatformsProp == 'none') {
|
||||
jpdfiumPlatforms = []
|
||||
} else {
|
||||
jpdfiumPlatforms = jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
|
||||
}
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
|
||||
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,17 +121,17 @@ 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");
|
||||
Path mountInfo = Path.of("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown usecase
|
||||
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() {
|
||||
|
||||
+18
-1
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -275,6 +277,21 @@ public class TempFileCleanupService {
|
||||
return totalDeletedCount.get();
|
||||
}
|
||||
|
||||
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
|
||||
if (maxAgeMillis <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long lastModified = Files.getLastModifiedTime(directory).toMillis();
|
||||
return (currentTime - lastModified) > maxAgeMillis;
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the system temp directory path based on configuration or system property. */
|
||||
private Path getSystemTempPath() {
|
||||
String systemTempDir =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -155,6 +155,7 @@ public class TempFileManager {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
try {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
log.debug("Deleted temp directory: {}", directory.toString());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
|
||||
|
||||
@@ -85,6 +85,18 @@ public class TempFileRegistry {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a temporary directory from the registry.
|
||||
*
|
||||
* @param directory The directory to unregister
|
||||
*/
|
||||
public void unregisterDirectory(Path directory) {
|
||||
if (directory != null) {
|
||||
tempDirectories.remove(directory);
|
||||
log.debug("Unregistered temp directory: {}", directory.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a third-party temporary file that requires special handling.
|
||||
*
|
||||
|
||||
+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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
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 stale 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"));
|
||||
Files.setLastModifiedTime(
|
||||
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
|
||||
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("keeps a fresh registered temp directory")
|
||||
void keepsFreshRegisteredDirectory() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
|
||||
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);
|
||||
|
||||
assertThat(Files.exists(regDir)).isTrue();
|
||||
}
|
||||
|
||||
@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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user