Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 4bfe1f0842 build(deps): bump madrapps/jacoco-report from 1.7.2 to 1.8.0
Bumps [madrapps/jacoco-report](https://github.com/madrapps/jacoco-report) from 1.7.2 to 1.8.0.
- [Release notes](https://github.com/madrapps/jacoco-report/releases)
- [Commits](https://github.com/madrapps/jacoco-report/compare/50d3aff4548aa991e6753342d9ba291084e63848...e51ce1f46f7f8b5331593f935e59cbaf44b84920)

---
updated-dependencies:
- dependency-name: madrapps/jacoco-report
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 22:23:54 +00:00
647 changed files with 5382 additions and 72870 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.13.2
pkgver=2.13.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.13.2
pkgver=2.13.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -222,7 +222,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
@@ -43,7 +43,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
@@ -247,7 +247,7 @@ jobs:
# so skip it for merge_group runs and workflow_dispatch.
if: github.event_name == 'pull_request'
id: jacoco
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0
with:
paths: |
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.3.1
cache-disabled: true
- name: Set up Python
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
cache-disabled: true
- name: Set up Docker Buildx
@@ -349,7 +349,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+3 -3
View File
@@ -73,7 +73,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -148,7 +148,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -252,7 +252,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
-12
View File
@@ -51,15 +51,3 @@ jobs:
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/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
secrets: inherit
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
+2 -2
View File
@@ -78,7 +78,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Set up Docker Buildx
id: buildx
@@ -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
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
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
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
+1 -6
View File
@@ -122,12 +122,7 @@ jobs:
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'
@@ -150,7 +145,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
gradle-version: 9.5.1
- name: Build with Gradle
run: ./gradlew build
+2 -4
View File
@@ -25,7 +25,6 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
@@ -35,13 +34,12 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+2 -25
View File
@@ -5,11 +5,6 @@ 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: |
@@ -107,20 +102,6 @@ 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)"
@@ -146,13 +127,9 @@ tasks:
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
# 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.
- |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$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" \
+3 -49
View File
@@ -130,34 +130,9 @@ 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:
- '{{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
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
# ============================================================
# Build
@@ -178,10 +153,8 @@ tasks:
build:proprietary:
desc: "Build for proprietary mode"
deps: [prepare]
vars:
PREVIEW: '{{.PREVIEW | default ""}}'
cmds:
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
- npx vite build editor --mode proprietary
build:saas:
desc: "Build for SaaS mode"
@@ -208,26 +181,8 @@ tasks:
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- '{{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
- npx vite build portal
storybook:
desc: "Start Storybook dev server"
@@ -333,7 +288,6 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project scripts/tsconfig.json
- npx tsc --noEmit --project editor/scripts/tsconfig.json
typecheck:prototypes:
+38 -12
View File
@@ -4,6 +4,8 @@ version: '3'
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
vars:
GITLEAKS: '8.30.0'
# 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: >-
@@ -41,9 +43,7 @@ vars:
':(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}}'
GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}'
tasks:
default:
@@ -84,13 +84,22 @@ tasks:
- test -d scripts/pre-commit/.venv
clean:
desc: "Remove the cached gitleaks binary and the tool virtualenv"
desc: "Remove the cache/build artifacts"
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
- task: '{{if eq OS "windows"}}clean-windows{{else}}clean-unix{{end}}'
clean-unix:
internal: true
cmds:
- rm -rf scripts/pre-commit/.venv .task/bin/gitleaks-*
# On Windows, use PowerShell so it matches the same paths and tolerates absent
# files without erroring.
clean-windows:
internal: true
ignore_error: true
cmds:
- powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue scripts/pre-commit/.venv, .task/bin/gitleaks-*"
# 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.
@@ -116,7 +125,7 @@ tasks:
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}})
gitleaks:
deps: [gitleaks-bin]
@@ -128,6 +137,23 @@ tasks:
gitleaks-bin:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
desc: "Ensure the pinned gitleaks binary is cached in .task/bin"
status:
- test -f {{.GITLEAKS_BIN}}
vars:
GL_ARCH: '{{if eq ARCH "amd64"}}x64{{else if eq ARCH "arm64"}}arm64{{else if eq ARCH "386"}}x32{{else}}{{ARCH}}{{end}}'
GL_PLATFORM: '{{OS}}_{{.GL_ARCH}}'
GL_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v{{.GITLEAKS}}/gitleaks_{{.GITLEAKS}}_{{.GL_PLATFORM}}'
# SHA-256 of each release asset, from gitleaks_{{.GITLEAKS}}_checksums.txt.
GL_SHA: >-
{{if eq .GL_PLATFORM "linux_x64"}}79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e
{{- else if eq .GL_PLATFORM "linux_arm64"}}b4cbbb6ddf7d1b2a603088cd03a4e3f7ce48ee7fd449b51f7de6ee2906f5fa2f
{{- else if eq .GL_PLATFORM "darwin_x64"}}ca221d012d247080c2f6f61f4b7a83bffa2453806b0c195c795bbe9a8c775ed5
{{- else if eq .GL_PLATFORM "darwin_arm64"}}b251ab2bcd4cd8ba9e56ff37698c033ebf38582b477d21ebd86586d927cf87e7
{{- else if eq .GL_PLATFORM "windows_x64"}}54fe94f644b832dd08e8c3a5915efb3bfa862386d59fb27ca0792cb687a83573
{{- end}}
cmds:
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
- cmd: bash scripts/pre-commit/install-gitleaks.sh "{{.GL_URL}}.tar.gz" "{{.GL_SHA}}" "{{.GITLEAKS_BIN}}"
platforms: [linux, darwin]
- cmd: powershell -NoProfile -File scripts/pre-commit/install-gitleaks.ps1 -Url "{{.GL_URL}}.zip" -Sha "{{.GL_SHA}}" -Dest "{{.GITLEAKS_BIN}}"
platforms: [windows]
-91
View File
@@ -78,80 +78,6 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
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}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:portal
vars:
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"
- 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"
- 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:
@@ -198,23 +124,6 @@ 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"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
+6 -6
View File
@@ -29,13 +29,13 @@ spotless {
}
}
dependencies {
api "com.google.guava:guava:${guavaVersion}"
api 'com.google.guava:guava:33.6.0-jre'
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:${commonsLang3}"
api 'org.apache.commons:commons-lang3:3.20.0'
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
@@ -60,7 +60,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api "com.stirling:jpdfium:${jpdfiumVersion}"
api 'com.stirling:jpdfium:1.0.2'
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,12 +75,12 @@ dependencies {
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
}
@@ -869,8 +869,8 @@ public class ApplicationProperties {
public static class System {
private String defaultLocale;
private boolean googlevisibility;
private boolean showUpdate = true;
private boolean showUpdateOnlyAdmin = true;
private boolean showUpdate;
private boolean showUpdateOnlyAdmin;
private boolean showSettingsWhenNoLogin = true;
private boolean customHTMLFiles;
private String tessdataDir;
@@ -244,7 +244,10 @@ public class SvgSanitizer {
return false;
}
return true;
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
}
private boolean isUrlAllowed(String url) {
@@ -1,191 +0,0 @@
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();
}
}
}
@@ -1,270 +0,0 @@
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-");
}
}
}
@@ -1,178 +0,0 @@
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();
}
}
}
@@ -1,244 +0,0 @@
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);
}
});
}
}
}
@@ -1,152 +0,0 @@
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);
}
}
}
}
@@ -1,180 +0,0 @@
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();
}
}
}
@@ -1,152 +0,0 @@
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();
}
}
}
}
@@ -1,492 +0,0 @@
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");
}
}
}
@@ -1,410 +0,0 @@
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");
}
}
}
@@ -1,259 +0,0 @@
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;
}
}
}
@@ -1,307 +0,0 @@
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();
}
}
}
@@ -1,365 +0,0 @@
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"));
}
}
}
@@ -1,379 +0,0 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/** Additional coverage for TempFileCleanupService branches not exercised by the base test. */
class TempFileCleanupServiceMoreTest {
@TempDir Path tempDir;
@Mock private TempFileRegistry registry;
@Mock private TempFileManager tempFileManager;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System system;
@Mock private ApplicationProperties.TempFileManagement tempFileManagement;
@InjectMocks private TempFileCleanupService cleanupService;
private Path systemTempDir;
private Path customTempDir;
private Path libreOfficeTempDir;
private AutoCloseable closeable;
@BeforeEach
void setUp() throws IOException {
closeable = MockitoAnnotations.openMocks(this);
systemTempDir = tempDir.resolve("systemTemp");
customTempDir = tempDir.resolve("customTemp");
libreOfficeTempDir = tempDir.resolve("libreOfficeTemp");
Files.createDirectories(systemTempDir);
Files.createDirectories(customTempDir);
Files.createDirectories(libreOfficeTempDir);
lenient().when(applicationProperties.getSystem()).thenReturn(system);
lenient().when(system.getTempFileManagement()).thenReturn(tempFileManagement);
lenient().when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
lenient()
.when(tempFileManagement.getLibreofficeDir())
.thenReturn(libreOfficeTempDir.toString());
lenient().when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
lenient().when(tempFileManagement.isStartupCleanup()).thenReturn(false);
lenient().when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
lenient().when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L);
}
@AfterEach
void tearDown() throws Exception {
closeable.close();
}
private static void backdate(Path file, long millisAgo) throws IOException {
Files.setLastModifiedTime(
file, FileTime.fromMillis(System.currentTimeMillis() - millisAgo));
}
@Nested
@DisplayName("isContainerMode")
class ContainerMode {
@Test
@DisplayName("Docker and Kubernetes are container modes; others are not")
void detectsContainerMachineTypes() {
ReflectionTestUtils.setField(cleanupService, "machineType", "Docker");
assertThat(
(Boolean)
ReflectionTestUtils.invokeMethod(
cleanupService, "isContainerMode"))
.isTrue();
ReflectionTestUtils.setField(cleanupService, "machineType", "Kubernetes");
assertThat(
(Boolean)
ReflectionTestUtils.invokeMethod(
cleanupService, "isContainerMode"))
.isTrue();
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
assertThat(
(Boolean)
ReflectionTestUtils.invokeMethod(
cleanupService, "isContainerMode"))
.isFalse();
}
}
@Nested
@DisplayName("getSystemTempPath")
class SystemTempPath {
@Test
@DisplayName("uses the configured system temp dir when set")
void usesConfiguredDir() {
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
Path path =
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
assertThat(path).isEqualTo(systemTempDir);
}
@Test
@DisplayName("falls back to java.io.tmpdir when unset")
void fallsBackToJavaTmpDir() {
when(tempFileManagement.getSystemTempDir()).thenReturn("");
Path path =
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
assertThat(path).isEqualTo(Path.of(System.getProperty("java.io.tmpdir")));
}
}
@Nested
@DisplayName("init")
class Init {
@Test
@DisplayName("creates configured temp directories that do not yet exist")
void createsMissingDirectories() {
Path newBase = tempDir.resolve("newBase");
Path newLo = tempDir.resolve("newLo");
when(tempFileManagement.getBaseTmpDir()).thenReturn(newBase.toString());
when(tempFileManagement.getLibreofficeDir()).thenReturn(newLo.toString());
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
cleanupService.init();
assertThat(Files.exists(newBase)).isTrue();
assertThat(Files.exists(newLo)).isTrue();
}
@Test
@DisplayName("runs startup cleanup when enabled")
void runsStartupCleanupWhenEnabled() throws IOException {
when(tempFileManagement.isStartupCleanup()).thenReturn(true);
when(registry.contains(any(File.class))).thenReturn(false);
// An old stirling temp file in the custom dir should be removed by startup cleanup.
Path stale = Files.createFile(customTempDir.resolve("stirling-pdf-stale.tmp"));
backdate(stale, 48L * 60 * 60 * 1000); // 48h old, beyond non-container 24h cutoff
cleanupService.init();
assertThat(Files.exists(stale)).isFalse();
}
}
@Nested
@DisplayName("scheduledCleanup")
class ScheduledCleanup {
@Test
@DisplayName("deletes registered temp directories and reports counts")
void deletesRegisteredDirectories() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
withIsolatedUserHome(cleanupService::scheduledCleanup);
// The registered directory was removed by GeneralUtils.deleteDirectory.
assertThat(Files.exists(regDir)).isFalse();
verify(tempFileManager).cleanupOldTempFiles(anyLong());
}
@Test
@DisplayName("skips a registered directory that no longer exists")
void skipsMissingRegisteredDirectory() {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
Set<Path> dirs = new HashSet<>();
dirs.add(tempDir.resolve("ghostDir"));
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
// No exception even though the directory does not exist.
withIsolatedUserHome(cleanupService::scheduledCleanup);
verify(registry).getTempDirectories();
}
}
@Nested
@DisplayName("cleanupUnregisteredFiles system-temp inclusion")
class CleanupUnregistered {
@Test
@DisplayName("includes the system temp dir when cleanupSystemTemp is enabled")
void includesSystemTempDir() throws Exception {
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(true);
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
when(registry.contains(any(File.class))).thenReturn(false);
// Old stirling file in the system temp dir should be deleted in container mode.
Path stale = Files.createFile(systemTempDir.resolve("stirling-pdf-sys.tmp"));
backdate(stale, 2L * 60 * 60 * 1000); // 2h old
int deleted =
(int)
ReflectionTestUtils.invokeMethod(
cleanupService,
"cleanupUnregisteredFiles",
true,
true,
3600000L);
assertThat(deleted).isGreaterThanOrEqualTo(1);
assertThat(Files.exists(stale)).isFalse();
}
}
@Nested
@DisplayName("registered-file skip and recursion depth")
class RegistryAndDepth {
@Test
@DisplayName("a registered file is never deleted")
void registeredFilePreserved() throws Exception {
Path registered = Files.createFile(systemTempDir.resolve("output_registered.pdf"));
backdate(registered, 2L * 60 * 60 * 1000);
// The registry reports the file as registered, so cleanup must skip it.
when(registry.contains(any(File.class))).thenReturn(true);
invokeCleanupDirectoryStreaming(systemTempDir, 0, false, 3600000L);
assertThat(Files.exists(registered)).isTrue();
}
@Test
@DisplayName("recursion stops once the maximum depth is exceeded")
void recursionDepthGuard() throws Exception {
// Starting beyond MAX_RECURSION_DEPTH (5) returns immediately without listing.
Path deepFile = Files.createFile(systemTempDir.resolve("output_deep.pdf"));
backdate(deepFile, 2L * 60 * 60 * 1000);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
invokeCleanupDirectoryStreaming(systemTempDir, 6, false, 3600000L);
// Depth guard hit: the file was not visited or deleted.
assertThat(Files.exists(deepFile)).isTrue();
}
}
@Nested
@DisplayName("cleanupLibreOfficeTempFiles")
class LibreOfficeCleanup {
@Test
@DisplayName("clears contents of registered libreoffice directories but keeps the dir")
void clearsLibreOfficeContents() throws IOException {
Path loDir = Files.createDirectories(tempDir.resolve("libreoffice-conv"));
Path inside = Files.createFile(loDir.resolve("output_lo.pdf"));
Set<Path> dirs = new HashSet<>();
dirs.add(loDir);
when(registry.getTempDirectories()).thenReturn(dirs);
when(registry.contains(any(File.class))).thenReturn(false);
cleanupService.cleanupLibreOfficeTempFiles();
// The file is removed (age ignored), directory itself remains.
assertThat(Files.exists(inside)).isFalse();
assertThat(Files.exists(loDir)).isTrue();
}
@Test
@DisplayName("ignores registered directories that are not libreoffice dirs")
void ignoresNonLibreOfficeDirs() throws IOException {
Path other = Files.createDirectories(tempDir.resolve("other-dir"));
Path keep = Files.createFile(other.resolve("output_keep.pdf"));
Set<Path> dirs = new HashSet<>();
dirs.add(other);
when(registry.getTempDirectories()).thenReturn(dirs);
cleanupService.cleanupLibreOfficeTempFiles();
// Not a libreoffice dir, so its contents are untouched.
assertThat(Files.exists(keep)).isTrue();
}
}
@Nested
@DisplayName("cleanupPDFBoxCache")
class PdfBoxCache {
@Test
@DisplayName("deletes an existing .pdfbox.cache file in the user home")
void deletesCacheFile() throws IOException {
Path fakeHome = Files.createDirectories(tempDir.resolve("home"));
Path cache = Files.createFile(fakeHome.resolve(".pdfbox.cache"));
String oldHome = System.getProperty("user.home");
try {
System.setProperty("user.home", fakeHome.toString());
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
assertThat(Files.exists(cache)).isFalse();
} finally {
System.setProperty("user.home", oldHome);
}
}
@Test
@DisplayName("is a no-op when no cache file exists")
void noOpWhenNoCache() throws IOException {
Path fakeHome = Files.createDirectories(tempDir.resolve("home2"));
String oldHome = System.getProperty("user.home");
try {
System.setProperty("user.home", fakeHome.toString());
// No exception when the cache file is absent.
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
assertThat(Files.exists(fakeHome.resolve(".pdfbox.cache"))).isFalse();
} finally {
System.setProperty("user.home", oldHome);
}
}
}
// Point user.home at a throwaway dir so the real ~/.pdfbox.cache is never touched.
private void withIsolatedUserHome(Runnable action) {
String oldHome = System.getProperty("user.home");
try {
Path fakeHome = Files.createDirectories(tempDir.resolve("isolated-home"));
System.setProperty("user.home", fakeHome.toString());
action.run();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
System.setProperty("user.home", oldHome);
}
}
private void invokeCleanupDirectoryStreaming(
Path directory, int depth, boolean containerMode, long maxAgeMillis) {
try {
Consumer<Path> noop = p -> {};
var method =
TempFileCleanupService.class.getDeclaredMethod(
"cleanupDirectoryStreaming",
Path.class,
boolean.class,
int.class,
long.class,
boolean.class,
Consumer.class);
method.setAccessible(true);
method.invoke(
cleanupService, directory, containerMode, depth, maxAgeMillis, false, noop);
} catch (Exception e) {
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
}
}
}
@@ -1,120 +0,0 @@
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();
}
}
}
@@ -1,204 +0,0 @@
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();
}
}
}
@@ -1,270 +0,0 @@
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();
}
}
}
@@ -1,218 +0,0 @@
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("&lt;b&gt;").contains("&amp;");
}
}
@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");
}
}
}
@@ -1,92 +0,0 @@
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());
}
}
}
@@ -1,162 +0,0 @@
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);
}
}
}
@@ -1,288 +0,0 @@
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"));
}
}
}
}
@@ -1,655 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.FormFieldWithCoordinates;
/**
* Additional branch coverage for {@link FormUtils}, complementing FormUtilsAdditionalTest and
* FormUtilsGapTest. Targets the display-label derivation chain, choice/radio value extraction and
* application, the modify-form type-change recreation path, and coordinate edge cases.
*/
class FormUtilsMoreTest {
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
private static SetupDocument createBasicDocument(PDDocument document) {
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
PDResources dr = new PDResources();
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
acroForm.setDefaultResources(dr);
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachWidget(
SetupDocument setup, PDTerminalField field, PDRectangle rectangle) throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page());
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm().getFields().add(field);
setup.page().getAnnotations().add(widget);
}
// ----------------------------------------------------------------------
// extractFormFields - field-type branches and display labels
// ----------------------------------------------------------------------
@Nested
@DisplayName("extractFormFields metadata")
class ExtractFormFieldsMetadata {
@Test
void comboBoxExtractsOptionsAndType() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("color");
combo.setOptions(List.of("Red", "Green"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
FormUtils.FormFieldInfo info = fields.get(0);
assertEquals("combobox", info.type());
assertNotNull(info.options());
assertTrue(info.options().contains("Red"));
}
}
@Test
void multiSelectListBoxReportsMultiSelect() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("items");
listBox.setMultiSelect(true);
listBox.setOptions(List.of("A", "B", "C"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("listbox", fields.get(0).type());
assertTrue(fields.get(0).multiSelect());
}
}
@Test
void fieldWithoutNameIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
// No partial name set -> fullyQualifiedName and partialName both null -> skipped.
PDTextField nameless = new PDTextField(setup.acroForm());
attachWidget(setup, nameless, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertTrue(fields.isEmpty());
}
}
@Test
void alternateFieldNameBecomesDisplayLabel() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("f1");
text.setAlternateFieldName("Customer Email");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("Customer Email", fields.get(0).label());
}
}
@Test
void tooltipBecomesDisplayLabelWhenNoAlternate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("f1");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
// Set the /TU tooltip on the widget.
text.getWidgets().get(0).getCOSObject().setString(COSName.TU, "Phone Number");
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("Phone Number", fields.get(0).label());
assertEquals("Phone Number", fields.get(0).tooltip());
}
}
@Test
void humanizedNameUsedWhenNoLabelSources() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("first_name");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
// humanizeName turns first_name -> "first name".
assertEquals("first name", fields.get(0).label());
}
}
@Test
void genericNameFallsBackToTypeLabel() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
// A 32+ hex char name is detected as UUID-like (generic), forcing the fallback.
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("cdc47b7041524571abcd93017fe77bf7");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("Text field 1", fields.get(0).label());
}
}
@Test
void choiceFieldCurrentValueIsJoined() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("items");
listBox.setMultiSelect(true);
listBox.setOptions(List.of("A", "B", "C"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
listBox.setValue(List.of("A", "C"));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("A,C", fields.get(0).value());
}
}
@Test
void fieldsAreSortedByPageThenOrderThenName() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField zebra = new PDTextField(setup.acroForm());
zebra.setPartialName("zebra");
attachWidget(setup, zebra, new PDRectangle(50, 700, 200, 20));
PDTextField apple = new PDTextField(setup.acroForm());
apple.setPartialName("apple");
attachWidget(setup, apple, new PDRectangle(50, 660, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(2, fields.size());
// pageOrder is assigned in tree order so zebra (added first) keeps order 0.
assertEquals("zebra", fields.get(0).name());
assertEquals(0, fields.get(0).pageOrder());
assertEquals(1, fields.get(1).pageOrder());
}
}
}
// ----------------------------------------------------------------------
// extractFormFieldsWithCoordinates - extra branches
// ----------------------------------------------------------------------
@Nested
@DisplayName("extractFormFieldsWithCoordinates extras")
class ExtractWithCoordinatesExtras {
@Test
void multilineAndReadOnlyFlagsAreReported() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("notes");
text.setMultiline(true);
text.setReadOnly(true);
attachWidget(setup, text, new PDRectangle(50, 600, 200, 80));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
assertTrue(fields.get(0).isMultiline());
assertTrue(fields.get(0).isReadOnly());
}
}
@Test
void comboBoxWithDistinctDisplayValuesPopulatesDisplayOptions() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("country");
// Distinct export vs display values triggers displayOptions to be sent.
combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
List<String> displayOptions = fields.get(0).getDisplayOptions();
assertNotNull(displayOptions);
assertTrue(displayOptions.contains("United States"));
}
}
@Test
void fontSizeExtractedFromDefaultAppearance() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("sized");
text.setDefaultAppearance("/Helv 14 Tf 0 g");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
FormFieldWithCoordinates.WidgetCoordinates wc = fields.get(0).getWidgets().get(0);
assertEquals(14f, wc.getFontSize(), 0.01f);
}
}
@Test
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("offpage");
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
// returns null, which is still added to the per-field widget list.
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
fields.get(0).getWidgets();
assertNotNull(widgets);
assertEquals(1, widgets.size());
assertNull(widgets.get(0));
}
}
@Test
void widgetWithNullRectangleIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("norect");
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setPage(setup.page());
// Deliberately leave rectangle unset.
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
text.setWidgets(widgets);
setup.acroForm().getFields().add(text);
setup.page().getAnnotations().add(widget);
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
assertNull(fields.get(0).getWidgets());
}
}
}
// ----------------------------------------------------------------------
// applyFieldValues - choice / radio / signature / button branches
// ----------------------------------------------------------------------
@Nested
@DisplayName("applyFieldValues field-type branches")
class ApplyFieldValuesBranches {
@Test
void comboBoxValueIsApplied() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("color");
combo.setOptions(List.of("Red", "Green", "Blue"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
FormUtils.applyFieldValues(doc, Map.of("color", "Green"), false);
assertThat(combo.getValue()).contains("Green");
}
}
@Test
void comboBoxNullValueClearsSelection() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("color");
combo.setOptions(List.of("Red", "Green"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
combo.setValue("Red");
java.util.Map<String, Object> values = new java.util.HashMap<>();
values.put("color", null);
FormUtils.applyFieldValues(doc, values, false);
// Null value routes to setValue("") which clears the prior "Red" selection.
assertFalse(combo.getValue().contains("Red"));
}
}
@Test
void multiSelectListBoxAppliesCommaSeparatedValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("items");
listBox.setMultiSelect(true);
listBox.setOptions(List.of("A", "B", "C"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
FormUtils.applyFieldValues(doc, Map.of("items", "A, C"), false);
assertThat(listBox.getValue()).containsExactlyInAnyOrder("A", "C");
}
}
@Test
void radioButtonValueIsApplied() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox other = new PDListBox(setup.acroForm());
other.setPartialName("dummy");
other.setOptions(List.of("x"));
attachWidget(setup, other, new PDRectangle(50, 500, 200, 20));
// Blank radio value path: no exception, value stays unset.
org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio =
new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(
setup.acroForm());
radio.setPartialName("choice");
attachWidget(setup, radio, new PDRectangle(50, 700, 20, 20));
FormUtils.applyFieldValues(doc, Map.of("choice", " "), false);
// No widgets configured with on-states, but the blank-skip branch must not throw.
assertNotNull(radio.getValueAsString());
}
}
@Test
void signatureAndPushButtonFieldsAreSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDSignatureField sig = new PDSignatureField(setup.acroForm());
sig.setPartialName("sig");
attachWidget(setup, sig, new PDRectangle(50, 700, 200, 40));
PDPushButton button = new PDPushButton(setup.acroForm());
button.setPartialName("btn");
attachWidget(setup, button, new PDRectangle(50, 640, 200, 40));
// Must complete without throwing; both branches are no-ops.
FormUtils.applyFieldValues(doc, Map.of("sig", "ignored", "btn", "ignored"), false);
}
}
@Test
void blankKeysAreSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("name");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
values.put(" ", "blankKey");
values.put("name", "value");
FormUtils.applyFieldValues(doc, values, false);
assertEquals("value", text.getValueAsString());
}
}
@Test
void unknownKeyIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("name");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.applyFieldValues(doc, Map.of("doesNotExist", "x"), false);
assertEquals("", text.getValueAsString());
}
}
}
// ----------------------------------------------------------------------
// modifyFormFields - type change (recreate) and choice in-place edits
// ----------------------------------------------------------------------
@Nested
@DisplayName("modifyFormFields advanced")
class ModifyFormFieldsAdvanced {
@Test
void changesFieldTypeViaRecreate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("toCombo");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"toCombo",
"toCombo",
"Pick one",
"combobox",
null,
null,
List.of("One", "Two"),
"One",
null);
FormUtils.modifyFormFields(doc, List.of(mod));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("combobox", fields.get(0).type());
assertEquals("toCombo", fields.get(0).name());
}
}
@Test
void inPlaceChoiceOptionAndMultiSelectUpdate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("list");
listBox.setOptions(List.of("A", "B"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"list",
null,
null,
"listbox", // same type -> in-place path
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
FormUtils.modifyFormFields(doc, List.of(mod));
PDField updated = doc.getDocumentCatalog().getAcroForm().getField("list");
assertTrue(updated instanceof PDListBox);
assertTrue(((PDListBox) updated).isMultiSelect());
assertThat(((PDListBox) updated).getOptions()).contains("X", "Y", "Z");
}
}
@Test
void unsupportedTargetTypeIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("keep");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("text", fields.get(0).type());
}
}
@Test
void renameAvoidsCollisionWithExistingField() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField a = new PDTextField(setup.acroForm());
a.setPartialName("alpha");
attachWidget(setup, a, new PDRectangle(50, 700, 200, 20));
PDTextField b = new PDTextField(setup.acroForm());
b.setPartialName("beta");
attachWidget(setup, b, new PDRectangle(50, 660, 200, 20));
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
List<String> names = new ArrayList<>();
for (FormUtils.FormFieldInfo info : FormUtils.extractFormFields(doc)) {
names.add(info.name());
}
assertEquals(2, names.size());
assertTrue(names.contains("alpha"));
// The renamed field cannot also be "alpha"; it gets a suffix.
assertTrue(names.stream().anyMatch(n -> n.startsWith("alpha_")));
}
}
@Test
void documentWithoutAcroFormIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
}
// ----------------------------------------------------------------------
// buildFillTemplateRecord - radio default branch
// ----------------------------------------------------------------------
@Test
void buildFillTemplateRadioUsesCurrentValue() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"choice", "Choice", "radio", "Yes", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("Yes", result.get("choice"));
}
@Test
void buildFillTemplateNullEntriesAreSkipped() {
List<FormUtils.FormFieldInfo> list = new ArrayList<>();
list.add(null);
list.add(
new FormUtils.FormFieldInfo(
"kept", "Kept", "text", "v", null, false, 0, false, null, 0));
Map<String, Object> result = FormUtils.buildFillTemplateRecord(list);
assertEquals(1, result.size());
assertTrue(result.containsKey("kept"));
}
// ----------------------------------------------------------------------
// resolveDisplayOptions / resolveOptions extra branches
// ----------------------------------------------------------------------
@Test
void resolveDisplayOptionsReturnsDistinctDisplayValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("c");
combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain"));
List<String> display = FormUtils.resolveDisplayOptions(combo);
assertThat(display).contains("United States", "Britain");
}
}
@Test
void resolveOptionsRadioUsesExportValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio =
new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(setup.acroForm());
radio.setExportValues(List.of("opt1", "opt2"));
assertEquals(List.of("opt1", "opt2"), FormUtils.resolveOptions(radio));
}
}
// ----------------------------------------------------------------------
// applyFieldValues strict mode
// ----------------------------------------------------------------------
@Test
void strictModeWrapsChoiceFailureInIoException() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
// A combo box with no /Opt array: setting a non-empty value triggers the
// "missing /Opt" IllegalArgumentException, which strict mode rethrows as IOException.
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("noOpts");
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
assertThrows(
IOException.class,
() -> FormUtils.applyFieldValues(doc, Map.of("noOpts", "X"), false, true));
}
}
}
@@ -1,335 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Branch coverage for {@link GeneralFormCopyUtils#copyAndTransformFormFields} and the {@link
* GeneralFormFieldTypeSupport} handlers, complementing GeneralFormCopyUtilsTest which only covers
* rotation and the empty-form early returns.
*/
class GeneralFormCopyUtilsMoreTest {
private static PDAcroForm newAcroForm(PDDocument document) {
PDAcroForm acroForm = new PDAcroForm(document);
PDResources dr = new PDResources();
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
acroForm.setDefaultResources(dr);
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static void addWidget(PDTerminalField field, PDPage page, PDRectangle rect)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rect);
widget.setPage(page);
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
field.setWidgets(widgets);
page.getAnnotations().add(widget);
}
// ----------------------------------------------------------------------
// copyAndTransformFormFields - real field copying
// ----------------------------------------------------------------------
@Nested
@DisplayName("copyAndTransformFormFields copying")
class CopyingFields {
@Test
void copiesTextCheckboxAndComboFields() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("name");
addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20));
sourceForm.getFields().add(text);
text.setValue("Alice");
PDCheckBox check = new PDCheckBox(sourceForm);
check.setPartialName("agree");
check.setExportValues(List.of("Yes"));
addWidget(check, sourcePage, new PDRectangle(50, 660, 16, 16));
sourceForm.getFields().add(check);
PDComboBox combo = new PDComboBox(sourceForm);
combo.setPartialName("color");
addWidget(combo, sourcePage, new PDRectangle(50, 620, 200, 20));
sourceForm.getFields().add(combo);
combo.setOptions(List.of("Red", "Green"));
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertNotNull(targetForm);
assertEquals(3, targetForm.getFields().size());
List<String> names = new ArrayList<>();
for (var f : targetForm.getFields()) {
names.add(f.getPartialName());
}
// Names are prefixed with page index during copy.
assertThat(names).contains("page0_name", "page0_agree", "page0_color");
}
}
@Test
void copiesFieldThroughMultiCellGridLayout() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("name");
addWidget(text, sourcePage, new PDRectangle(100, 100, 200, 20));
sourceForm.getFields().add(text);
// 2x2 layout exercises the scale/offset arithmetic for cell placement.
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 4, 2, 2, 300f, 396f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertEquals(1, targetForm.getFields().size());
assertEquals("page0_name", targetForm.getFields().get(0).getPartialName());
assertEquals(1, targetForm.getFields().get(0).getWidgets().size());
}
}
@Test
void skipsPagesWithoutAnnotations() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
source.addPage(new PDPage(PDRectangle.A4)); // no annotations
target.addPage(new PDPage(PDRectangle.A4));
// Source has an AcroForm with a field on a different (non-existent here) page,
// but page 0 has no annotations -> the per-page copy is skipped.
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("ghost");
sourceForm.getFields().add(text);
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
// Form is created but no widgets were copied.
assertNotNull(targetForm);
assertTrue(targetForm.getFields().isEmpty());
}
}
@Test
void skipsWhenRowIndexExceedsRows() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage page0 = new PDPage(PDRectangle.A4);
PDPage page1 = new PDPage(PDRectangle.A4);
source.addPage(page0);
source.addPage(page1);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
PDTextField a = new PDTextField(sourceForm);
a.setPartialName("a");
addWidget(a, page0, new PDRectangle(10, 10, 100, 20));
sourceForm.getFields().add(a);
PDTextField b = new PDTextField(sourceForm);
b.setPartialName("b");
addWidget(b, page1, new PDRectangle(10, 10, 100, 20));
sourceForm.getFields().add(b);
// cols=1, rows=1, pagesPerSheet=2 -> second page maps to rowIndex 1 (>= rows) ->
// skipped.
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 2, 2, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertEquals(1, targetForm.getFields().size());
assertEquals("page0_a", targetForm.getFields().get(0).getPartialName());
}
}
@Test
void skipsWhenDestinationPageMissing() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
// Target has NO pages, so destinationPageIndex 0 is out of bounds.
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("name");
addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20));
sourceForm.getFields().add(text);
assertDoesNotThrow(
() ->
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f));
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertTrue(targetForm.getFields().isEmpty());
}
}
@Test
void uniquifiesDuplicateFieldNamesAcrossPages() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
// Two separate fields placed on the same source page with the same partial name
// would clash; the copier must generate distinct names.
PDTextField one = new PDTextField(sourceForm);
one.setPartialName("dup");
addWidget(one, sourcePage, new PDRectangle(50, 700, 100, 20));
sourceForm.getFields().add(one);
PDTextField two = new PDTextField(sourceForm);
two.setPartialName("dup");
addWidget(two, sourcePage, new PDRectangle(50, 660, 100, 20));
sourceForm.getFields().add(two);
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertEquals(2, targetForm.getFields().size());
List<String> names = new ArrayList<>();
for (var f : targetForm.getFields()) {
names.add(f.getPartialName());
}
// First keeps page0_dup; the second is suffixed.
assertTrue(names.contains("page0_dup"));
assertTrue(names.stream().anyMatch(n -> n.startsWith("page0_dup_")));
}
}
}
// ----------------------------------------------------------------------
// GeneralFormFieldTypeSupport - forField / createField / copyFromOriginal
// ----------------------------------------------------------------------
@Nested
@DisplayName("GeneralFormFieldTypeSupport")
class TypeSupport {
@Test
void forFieldNullReturnsNull() {
assertNull(GeneralFormFieldTypeSupport.forField(null));
}
@Test
void forFieldResolvesEachConcreteType() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
assertEquals(
GeneralFormFieldTypeSupport.TEXT,
GeneralFormFieldTypeSupport.forField(new PDTextField(form)));
assertEquals(
GeneralFormFieldTypeSupport.CHECKBOX,
GeneralFormFieldTypeSupport.forField(new PDCheckBox(form)));
assertEquals(
GeneralFormFieldTypeSupport.COMBOBOX,
GeneralFormFieldTypeSupport.forField(new PDComboBox(form)));
assertEquals(
GeneralFormFieldTypeSupport.BUTTON,
GeneralFormFieldTypeSupport.forField(new PDPushButton(form)));
}
}
@Test
void createFieldProducesMatchingInstance() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
PDTerminalField text = GeneralFormFieldTypeSupport.TEXT.createField(form);
assertTrue(text instanceof PDTextField);
PDTerminalField check = GeneralFormFieldTypeSupport.CHECKBOX.createField(form);
assertTrue(check instanceof PDCheckBox);
}
}
@Test
void copyFromOriginalTransfersComboOptions() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
PDComboBox src = new PDComboBox(form);
src.setPartialName("src");
src.setOptions(List.of("A", "B"));
PDComboBox dst = new PDComboBox(form);
dst.setPartialName("dst");
GeneralFormFieldTypeSupport.COMBOBOX.copyFromOriginal(src, dst);
assertThat(dst.getOptions()).contains("A", "B");
}
}
@Test
void copyFromOriginalTransfersTextValue() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
PDTextField src = new PDTextField(form);
src.setPartialName("src");
src.setValue("hello");
PDTextField dst = new PDTextField(form);
dst.setPartialName("dst");
dst.setDefaultAppearance("/Helv 12 Tf 0 g");
GeneralFormFieldTypeSupport.TEXT.copyFromOriginal(src, dst);
assertEquals("hello", dst.getValueAsString());
}
}
@Test
void typeNameAndFallbackWidgetNameExposed() {
assertEquals("text", GeneralFormFieldTypeSupport.TEXT.typeName());
assertEquals("textField", GeneralFormFieldTypeSupport.TEXT.fallbackWidgetName());
assertEquals("checkbox", GeneralFormFieldTypeSupport.CHECKBOX.typeName());
}
}
}
@@ -1,422 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
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 org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Branch-coverage gap tests for {@link GeneralUtils}. Targets size parsing/formatting, page-list
* and range handling, version comparison, URL validation, script/pipeline extraction validation,
* and the Ghostscript optimize failure paths not exercised by the existing GeneralUtils*Test files.
*/
class GeneralUtilsMoreTest {
@Nested
@DisplayName("convertSizeToBytes with explicit default unit")
class ConvertSizeWithDefaultUnitTests {
@Test
@DisplayName("invalid default unit throws IllegalArgumentException")
void invalidDefaultUnitThrows() {
assertThatThrownBy(() -> GeneralUtils.convertSizeToBytes("100", "ZB"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid default unit");
}
@ParameterizedTest(name = "value \"5\" with default unit {0} -> {1} bytes")
@CsvSource({"B, 5", "KB, 5120", "MB, 5242880", "GB, 5368709120", "TB, 5497558138880"})
@DisplayName("numeric value uses the supplied default unit")
void numericValueUsesDefaultUnit(String unit, long expected) {
assertEquals(expected, GeneralUtils.convertSizeToBytes("5", unit));
}
@Test
@DisplayName("lowercase default unit is normalized")
void lowercaseDefaultUnit() {
assertEquals(5L * 1024 * 1024, GeneralUtils.convertSizeToBytes("5", "mb"));
}
@Test
@DisplayName("explicit suffix overrides default unit")
void explicitSuffixOverridesDefault() {
// "2KB" should parse as KB even though default unit is GB.
assertEquals(2048L, GeneralUtils.convertSizeToBytes("2KB", "GB"));
}
@Test
@DisplayName("null default unit falls back to MB")
void nullDefaultUnitFallsBackToMb() {
assertEquals(3L * 1024 * 1024, GeneralUtils.convertSizeToBytes("3", null));
}
}
@Nested
@DisplayName("convertSizeToBytes suffix and edge parsing")
class ConvertSizeSuffixTests {
@Test
@DisplayName("comma decimal separator and embedded spaces are handled")
void commaAndSpaces() {
// "2,5 GB" -> "2.5GB" after normalization.
assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2,5 GB"));
}
@Test
@DisplayName("bare B suffix parses as bytes")
void bareBytes() {
assertEquals(42L, GeneralUtils.convertSizeToBytes("42B"));
}
@Test
@DisplayName("non-numeric body returns null")
void nonNumericReturnsNull() {
assertNull(GeneralUtils.convertSizeToBytes("abcMB"));
}
@Test
@DisplayName("negative value returns null")
void negativeReturnsNull() {
assertNull(GeneralUtils.convertSizeToBytes("-1KB"));
}
@Test
@DisplayName("zero is a valid size")
void zeroIsValid() {
assertEquals(0L, GeneralUtils.convertSizeToBytes("0MB"));
}
}
@Nested
@DisplayName("formatBytes boundaries")
class FormatBytesTests {
@Test
@DisplayName("negative bytes report invalid size")
void negativeInvalid() {
assertEquals("Invalid size", GeneralUtils.formatBytes(-1));
}
@Test
@DisplayName("terabyte range uses TB suffix")
void terabyteRange() {
long oneTb = 1024L * 1024L * 1024L * 1024L;
assertEquals("1.00 TB", GeneralUtils.formatBytes(oneTb));
}
@Test
@DisplayName("upper KB boundary just below a megabyte")
void kbBoundary() {
assertThat(GeneralUtils.formatBytes(1024L * 1024L - 1)).endsWith("KB");
}
}
@Nested
@DisplayName("parsePageList String overload")
class ParsePageListStringTests {
@Test
@DisplayName("null pages defaults to first page")
void nullDefaultsToFirst() {
// Cast disambiguates the String vs String[] overloads for a null literal.
assertEquals(List.of(1), GeneralUtils.parsePageList((String) null, 5, true));
}
@Test
@DisplayName("comma-separated list expands across tokens")
void commaSeparated() {
assertEquals(List.of(1, 3, 5), GeneralUtils.parsePageList("1,3,5", 5, true));
}
@Test
@DisplayName("'all' keyword via String overload returns every page")
void allKeyword() {
assertEquals(List.of(1, 2, 3), GeneralUtils.parsePageList("all", 3, true));
}
@Test
@DisplayName("two-argument overload defaults to zero-based output")
void twoArgOverloadZeroBased() {
assertEquals(List.of(0, 1, 2), GeneralUtils.parsePageList(new String[] {"1-3"}, 5));
}
@Test
@DisplayName("large in-range request stays within the max-size guard")
void largeRequestWithinGuard() {
// Pages are clamped to [1, total], so a wide range never trips the maxSize guard.
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1-500"}, 500, true);
assertEquals(500, result.size());
}
}
@Nested
@DisplayName("range and single-page handling")
class RangeHandlingTests {
@Test
@DisplayName("open-ended range extends to the last page")
void openEndedRange() {
assertEquals(
List.of(3, 4, 5), GeneralUtils.parsePageList(new String[] {"3-"}, 5, true));
}
@Test
@DisplayName("invalid range bounds are skipped, valid tokens remain")
void invalidRangeSkipped() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"x-y", "2"}, 5, true);
assertEquals(List.of(2), result);
}
@Test
@DisplayName("out-of-range single page is dropped")
void outOfRangeSinglePage() {
assertTrue(GeneralUtils.parsePageList(new String[] {"99"}, 5, true).isEmpty());
}
@Test
@DisplayName("non-numeric single page is dropped")
void nonNumericSinglePage() {
assertTrue(GeneralUtils.parsePageList(new String[] {"abc"}, 5, true).isEmpty());
}
@Test
@DisplayName("range partially outside the document keeps in-bounds pages")
void rangePartlyOutOfBounds() {
assertEquals(List.of(4, 5), GeneralUtils.parsePageList(new String[] {"4-99"}, 5, true));
}
}
@Nested
@DisplayName("isVersionHigher")
class VersionTests {
@ParameterizedTest(name = "{0} > {1} == {2}")
@CsvSource({
"2.0.0, 1.9.9, true",
"1.0.0, 1.0.0, false",
"1.0, 1.0.1, false",
"1.0.1, 1.0, true",
"1.2, 1.10, false"
})
@DisplayName("compares version components numerically")
void comparesComponents(String a, String b, boolean expected) {
assertEquals(expected, GeneralUtils.isVersionHigher(a, b));
}
@Test
@DisplayName("null arguments yield false")
void nullArgs() {
assertFalse(GeneralUtils.isVersionHigher(null, "1.0"));
assertFalse(GeneralUtils.isVersionHigher("1.0", null));
}
@Test
@DisplayName("non-numeric component throws NumberFormatException")
void nonNumericComponentThrows() {
assertThrows(
NumberFormatException.class, () -> GeneralUtils.isVersionHigher("1.x", "1.0"));
}
}
@Nested
@DisplayName("isValidURL")
class ValidUrlTests {
@ParameterizedTest
@ValueSource(strings = {"https://example.com", "http://example.com/path?q=1"})
@DisplayName("well-formed external URLs are valid")
void validUrls(String url) {
assertTrue(GeneralUtils.isValidURL(url));
}
@ParameterizedTest
@ValueSource(strings = {"htp:/bad", "not a url", "://missing-scheme"})
@DisplayName("malformed URLs are rejected")
void invalidUrls(String url) {
assertFalse(GeneralUtils.isValidURL(url));
}
}
@Nested
@DisplayName("isValidUUID")
class UuidTests {
@Test
@DisplayName("null is not a valid UUID")
void nullUuid() {
assertFalse(GeneralUtils.isValidUUID(null));
}
@Test
@DisplayName("well-formed UUID is accepted")
void validUuid() {
assertTrue(GeneralUtils.isValidUUID("123e4567-e89b-12d3-a456-426614174000"));
}
@Test
@DisplayName("garbage string is rejected")
void garbageUuid() {
assertFalse(GeneralUtils.isValidUUID("xyz"));
}
}
@Nested
@DisplayName("createDir failure path")
class CreateDirFailureTests {
@Test
@DisplayName("returns false when directory creation throws IOException")
void createDirIoFailure(@TempDir Path tempDir) throws IOException {
// A regular file at the target path makes createDirectories fail.
Path asFile = tempDir.resolve("not-a-dir");
Files.writeString(asFile, "blocker");
Path child = asFile.resolve("child");
assertFalse(GeneralUtils.createDir(child.toString()));
}
}
@Nested
@DisplayName("extractScript validation")
class ExtractScriptTests {
@Test
@DisplayName("null or blank name is rejected")
void nullOrBlank() {
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(null));
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(" "));
}
@ParameterizedTest
@ValueSource(strings = {"../evil.py", "dir/script.py"})
@DisplayName("path-traversal characters are rejected")
void pathTraversalRejected(String name) {
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(name));
}
@Test
@DisplayName("name outside the allow-list is rejected")
void notInAllowList() {
assertThatThrownBy(() -> GeneralUtils.extractScript("random.py"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("png_to_webp.py");
}
}
@Nested
@DisplayName("extractPipeline invalid configuration")
class ExtractPipelineTests {
@Test
@DisplayName("missing classpath resource surfaces as IOException")
void missingResource(@TempDir Path tempDir) {
// Point the pipeline path at a temp dir; default pipeline JSONs are absent from
// the common module test classpath, so extraction fails with an IOException.
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getPipelinePath).thenReturn(tempDir.toString());
assertThrows(IOException.class, GeneralUtils::extractPipeline);
}
}
}
@Nested
@DisplayName("optimizePdfWithGhostscript failure handling")
class OptimizeGhostscriptTests {
@Test
@DisplayName("non-zero return code raises a Ghostscript exception")
void nonZeroReturnCode() throws Exception {
ProcessExecutor.ProcessExecutorResult result =
mock(ProcessExecutor.ProcessExecutorResult.class);
when(result.getMessages()).thenReturn("some ghostscript chatter");
when(result.getRc()).thenReturn(1);
ProcessExecutor executor = mock(ProcessExecutor.class);
// doReturn avoids referencing the checked-exception-declaring method during stubbing
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
assertThrows(
IOException.class,
() -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3}));
}
}
@Test
@DisplayName("detected critical Ghostscript error is rethrown")
void criticalErrorDetected() throws Exception {
ProcessExecutor.ProcessExecutorResult result =
mock(ProcessExecutor.ProcessExecutorResult.class);
when(result.getMessages()).thenReturn("Page 1\ncould not draw this page");
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
assertThatThrownBy(
() -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3}))
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
}
}
}
@Nested
@DisplayName("selectBestSiteLocalIp edge cases")
class SelectBestIpTests {
@Test
@DisplayName("empty interface list returns null")
void emptyList() {
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of()));
}
@Test
@DisplayName("non-private routable-style site-local IP still scores and is selected")
void otherRangeStillSelected() {
GeneralUtils.NetworkInterfaceInfo other =
new GeneralUtils.NetworkInterfaceInfo(
"eth0",
"Realtek PCIe GbE Family Controller",
2,
true,
false,
false,
false,
true,
List.of("172.16.5.5"));
assertEquals("172.16.5.5", GeneralUtils.selectBestSiteLocalIp(List.of(other)));
}
}
}
@@ -1,195 +0,0 @@
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.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
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.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 org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
/**
* Gap-filling tests for {@link PdfToCbrUtils#convertPdfToCbr} that drive the real PDFBox render
* loop with a tiny one-page PDF and mock the external {@code rar} process so the archive-creation
* branch is exercised without any external tool.
*/
class PdfToCbrUtilsMoreTest {
/** A one-page PDF containing a small embedded image so the renderer produces a PNG. */
private static byte[] onePageImagePdf() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(new PDRectangle(72, 72));
doc.addPage(page);
BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(Color.BLUE);
g.fillRect(0, 0, 16, 16);
g.dispose();
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, img);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(pdImage, 0, 0, 72, 72);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static MultipartFile pdfMultipart(byte[] bytes) {
return new MockMultipartFile("file", "comic.pdf", "application/pdf", bytes);
}
private static CustomPDFDocumentFactory factoryReturning(PDDocument document)
throws IOException {
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
when(factory.load(any(MultipartFile.class))).thenReturn(document);
return factory;
}
@Nested
@DisplayName("convertPdfToCbr - rar process branches")
class RarProcessTests {
@Test
@DisplayName("non-zero rar exit code surfaces as a processing exception")
void rarNonZeroExit() throws Exception {
PDDocument doc = Loader.loadPDF(onePageImagePdf());
CustomPDFDocumentFactory factory = factoryReturning(doc);
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(1);
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.INSTALL_APP))
.thenReturn(executor);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(IOException.class);
}
doc.close();
}
@Test
@DisplayName("rc=0 but missing rar output file raises 'RAR file was not created'")
void rarFileNotCreated() throws Exception {
PDDocument doc = Loader.loadPDF(onePageImagePdf());
CustomPDFDocumentFactory factory = factoryReturning(doc);
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
ProcessExecutor executor = mock(ProcessExecutor.class);
// No real rar runs, so the expected output.cbr is never produced.
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.INSTALL_APP))
.thenReturn(executor);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(IOException.class)
.hasMessageContaining("RAR");
}
doc.close();
}
@Test
@DisplayName("an interrupted rar process is wrapped and the thread interrupt is restored")
void rarInterrupted() throws Exception {
PDDocument doc = Loader.loadPDF(onePageImagePdf());
CustomPDFDocumentFactory factory = factoryReturning(doc);
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doThrow(new InterruptedException("boom"))
.when(executor)
.runCommandWithOutputHandling(anyList(), any());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.INSTALL_APP))
.thenReturn(executor);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(Exception.class);
} finally {
// Clear the interrupt flag set by the handler so it doesn't leak into later tests.
Thread.interrupted();
doc.close();
}
}
}
@Nested
@DisplayName("convertPdfToCbr - document validation")
class DocumentValidationTests {
@Test
@DisplayName("a zero-page document raises a no-pages exception before rendering")
void zeroPageDocument() throws Exception {
try (PDDocument empty = new PDDocument()) {
CustomPDFDocumentFactory factory = factoryReturning(empty);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(Exception.class);
}
}
}
@Nested
@DisplayName("isPdfFile")
class IsPdfFileTests {
@Test
@DisplayName("a .cbr file is not a PDF")
void cbrIsNotPdf() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbr");
assertThat(PdfToCbrUtils.isPdfFile(file)).isFalse();
}
}
}
@@ -1,316 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Further gap-filling tests for {@link PdfUtils}, complementing {@code PdfUtilsTest} and {@code
* PdfUtilsGapTest}: the form-XObject recursion in image discovery, the found-text branch, the
* ApplicationProperties-present DPI lookups, the rotated/duplicate page-size paths, and the
* multi-frame TIFF input path of imageToPdf.
*/
class PdfUtilsMoreTest {
// ---- helpers ------------------------------------------------------------
/** Builds a PDF whose pages each show the given text phrase. */
private static PDDocument docWithText(String... pageTexts) throws IOException {
PDDocument doc = new PDDocument();
for (String text : pageTexts) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(100, 700);
cs.showText(text);
cs.endText();
}
}
return doc;
}
/** A small one-page PDF serialized to bytes. */
private static byte[] simplePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** Builds an ApplicationProperties whose system reports the given max DPI. */
private static ApplicationProperties propsWithMaxDpi(int dpi) {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().setMaxDPI(dpi);
return props;
}
/** Encodes a multi-frame TIFF (two solid-colour frames) to bytes. */
private static byte[] multiFrameTiff() throws IOException {
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
writer.setOutput(ios);
ImageWriteParam param = writer.getDefaultWriteParam();
writer.prepareWriteSequence(null);
for (Color c : new Color[] {Color.RED, Color.BLUE}) {
BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(c);
g.fillRect(0, 0, 16, 16);
g.dispose();
writer.writeToSequence(new IIOImage(img, null, null), param);
}
writer.endWriteSequence();
}
writer.dispose();
return baos.toByteArray();
}
// ---- getAllImages recursion --------------------------------------------
@Nested
@DisplayName("getAllImages with form XObjects")
class GetAllImagesForm {
@Test
@DisplayName("images nested inside a form XObject are discovered recursively")
void recursesIntoFormXObject() throws IOException {
try (PDDocument doc = new PDDocument()) {
// Build a form XObject that itself holds an image in its resources.
PDFormXObject form = new PDFormXObject(doc);
form.setResources(new PDResources());
BufferedImage bi = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
PDImageXObject nested = LosslessFactory.createFromImage(doc, bi);
form.getResources().add(nested);
PDResources pageResources = new PDResources();
pageResources.add(form);
assertThat(PdfUtils.getAllImages(pageResources)).hasSize(1);
}
}
}
// ---- hasText found branch ----------------------------------------------
@Nested
@DisplayName("hasText found branch")
class HasTextFound {
@Test
@DisplayName("returns true when the phrase is present on a searched page")
void findsPhrase() throws IOException {
try (PDDocument doc = docWithText("NeedleInHaystack")) {
assertThat(PdfUtils.hasText(doc, "all", "NeedleInHaystack")).isTrue();
}
}
@Test
@DisplayName("returns true when the phrase is on the requested page only")
void findsPhraseOnSecondPage() throws IOException {
try (PDDocument doc = docWithText("first", "SecondMarker")) {
assertThat(PdfUtils.hasText(doc, "2", "SecondMarker")).isTrue();
}
}
}
// ---- convertFromPdf with ApplicationProperties present ------------------
@Nested
@DisplayName("convertFromPdf honouring configured max DPI")
class ConvertFromPdfWithProps {
@Test
@DisplayName("DPI under the configured limit renders; properties branch is taken")
void underConfiguredLimitRenders() throws Exception {
byte[] bytes = simplePdfBytes();
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
PDDocument doc = new PDDocument();
doc.addPage(new PDPage(new PDRectangle(20f, 20f)));
when(factory.load(bytes)).thenReturn(doc);
try (MockedStatic<ApplicationContextProvider> ctx =
Mockito.mockStatic(ApplicationContextProvider.class)) {
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(propsWithMaxDpi(200));
byte[] out =
PdfUtils.convertFromPdf(
factory, bytes, "png", ImageType.RGB, true, 72, "doc", true);
assertThat(out).isNotEmpty();
}
}
@Test
@DisplayName("DPI above the configured limit throws using the configured maximum")
void aboveConfiguredLimitThrows() {
byte[] bytes = new byte[] {1, 2, 3};
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
try (MockedStatic<ApplicationContextProvider> ctx =
Mockito.mockStatic(ApplicationContextProvider.class)) {
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(propsWithMaxDpi(100));
// 150 exceeds the configured limit of 100, so the limit check fires before loading.
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() ->
PdfUtils.convertFromPdf(
factory,
bytes,
"png",
ImageType.RGB,
true,
150,
"doc",
true));
}
}
@Test
@DisplayName("combined-image mode reuses the cached size for duplicate pages")
void combinedImageReusesDuplicatePageSize() throws Exception {
byte[] bytes = simplePdfBytes();
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
PDDocument doc = new PDDocument();
// Two identically-sized pages: the second hits the size cache.
doc.addPage(new PDPage(new PDRectangle(20f, 30f)));
doc.addPage(new PDPage(new PDRectangle(20f, 30f)));
when(factory.load(bytes)).thenReturn(doc);
byte[] out =
PdfUtils.convertFromPdf(
factory, bytes, "png", ImageType.RGB, true, 36, "doc", true);
assertThat(out).isNotEmpty();
}
@Test
@DisplayName("combined-image mode swaps dimensions for a rotated page")
void combinedImageRotatedPage() throws Exception {
byte[] bytes = simplePdfBytes();
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
PDDocument doc = new PDDocument();
PDPage rotated = new PDPage(new PDRectangle(20f, 30f));
rotated.setRotation(90);
doc.addPage(rotated);
when(factory.load(bytes)).thenReturn(doc);
byte[] out =
PdfUtils.convertFromPdf(
factory, bytes, "png", ImageType.RGB, true, 36, "doc", true);
assertThat(out).isNotEmpty();
}
}
// ---- convertPdfToPdfImage with ApplicationProperties present ------------
@Nested
@DisplayName("convertPdfToPdfImage honouring configured DPI")
class ConvertPdfToPdfImageWithProps {
@Test
@DisplayName("renders using the configured max DPI when properties are present")
void usesConfiguredDpi() throws IOException {
try (MockedStatic<ApplicationContextProvider> ctx =
Mockito.mockStatic(ApplicationContextProvider.class)) {
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(propsWithMaxDpi(72));
try (PDDocument source = new PDDocument()) {
source.addPage(new PDPage(new PDRectangle(12f, 18f)));
try (PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
}
}
}
}
}
// ---- imageToPdf with a multi-frame TIFF --------------------------------
@Nested
@DisplayName("imageToPdf with TIFF input")
class ImageToPdfTiff {
@Test
@DisplayName("a multi-frame TIFF produces one page per frame")
void multiFrameTiffBecomesMultiplePages() throws IOException {
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
when(factory.createNewDocument()).thenReturn(new PDDocument());
MockMultipartFile tiff =
new MockMultipartFile("file", "scan.tiff", "image/tiff", multiFrameTiff());
byte[] pdfOut =
PdfUtils.imageToPdf(
new MultipartFile[] {tiff}, "fillPage", false, "color", factory);
assertThat(pdfOut).isNotEmpty();
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertThat(doc.getNumberOfPages()).isEqualTo(2);
}
}
@Test
@DisplayName("a .tif extension is also handled by the TIFF reader path")
void tifExtensionHandled() throws IOException {
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
when(factory.createNewDocument()).thenReturn(new PDDocument());
MockMultipartFile tif =
new MockMultipartFile(
"file",
"scan.tif",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
multiFrameTiff());
byte[] pdfOut =
PdfUtils.imageToPdf(
new MultipartFile[] {tif}, "fillPage", false, "color", factory);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertThat(doc.getNumberOfPages()).isEqualTo(2);
}
}
}
}
@@ -1,184 +0,0 @@
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.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
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.mockito.MockedConstruction;
import org.mockito.Mockito;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
/**
* Tests that drive {@link ProcessExecutor#runCommandWithOutputHandling} through its full
* output-handling logic by intercepting {@link ProcessBuilder} construction with {@link
* MockedConstruction}. The {@link Process} is mocked, so no real OS process is ever started.
*/
class ProcessExecutorMoreTest {
private ProcessExecutor qpdfExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
}
private ProcessExecutor ghostscriptExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
}
/** Configure a mocked Process with given streams, completion flag and exit code. */
private static Process mockedProcess(
String stdout, String stderr, boolean finished, int exitCode)
throws InterruptedException {
Process process = mock(Process.class);
when(process.getInputStream())
.thenReturn(new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)));
when(process.getErrorStream())
.thenReturn(new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8)));
when(process.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(finished);
when(process.exitValue()).thenReturn(exitCode);
when(process.descendants()).thenReturn(Stream.empty());
return process;
}
/** Stub every constructed ProcessBuilder so start() returns the supplied process. */
private MockedConstruction<ProcessBuilder> stubProcessBuilder(Process process) {
return Mockito.mockConstruction(
ProcessBuilder.class,
(mockBuilder, context) -> {
when(mockBuilder.start()).thenReturn(process);
when(mockBuilder.directory(any())).thenReturn(mockBuilder);
});
}
@Nested
@DisplayName("runCommandWithOutputHandling - exit code handling")
class ExitCodeTests {
@Test
@DisplayName("a successful command (exit 0) returns rc=0 and captured output")
void successReturnsZero() throws Exception {
Process process = mockedProcess("hello output", "", true, 0);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
ProcessExecutorResult result =
qpdfExecutor().runCommandWithOutputHandling(List.of("qpdf", "--version"));
assertThat(result.getRc()).isEqualTo(0);
assertThat(result.getMessages()).contains("hello output");
}
}
@Test
@DisplayName("a non-zero exit code with error output throws an IOException")
void nonZeroExitThrows() throws Exception {
Process process = mockedProcess("", "fatal: boom", true, 2);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
ghostscriptExecutor()
.runCommandWithOutputHandling(
List.of("gs", "-bad")))
.isInstanceOf(IOException.class)
.hasMessageContaining("exit code 2");
}
}
@Test
@DisplayName("a non-zero exit code without error output still throws with the log tail")
void nonZeroExitNoStderrThrows() throws Exception {
Process process = mockedProcess("some stdout only", "", true, 5);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
ghostscriptExecutor()
.runCommandWithOutputHandling(List.of("gs", "x")))
.isInstanceOf(IOException.class)
.hasMessageContaining("exit code 5");
}
}
}
@Nested
@DisplayName("runCommandWithOutputHandling - qpdf special-casing")
class QpdfTests {
@Test
@DisplayName("qpdf exit code 3 is treated as success-with-warnings, not a failure")
void qpdfExitThreeIsWarning() throws Exception {
Process process = mockedProcess("", "WARNING: minor issue", true, 3);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
ProcessExecutorResult result =
qpdfExecutor()
.runCommandWithOutputHandling(List.of("qpdf", "--check", "in.pdf"));
assertThat(result.getRc()).isEqualTo(3);
}
}
@Test
@DisplayName("qpdf exit code 2 is still a hard failure")
void qpdfExitTwoFails() throws Exception {
Process process = mockedProcess("", "ERROR: broken", true, 2);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
qpdfExecutor()
.runCommandWithOutputHandling(
List.of("qpdf", "in.pdf")))
.isInstanceOf(IOException.class);
}
}
}
@Nested
@DisplayName("runCommandWithOutputHandling - timeout")
class TimeoutTests {
@Test
@DisplayName("a process that never finishes is destroyed and an IOException is thrown")
void timeoutThrows() throws Exception {
Process process = mockedProcess("", "", false, 0);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
qpdfExecutor()
.runCommandWithOutputHandling(
List.of("qpdf", "slow")))
.isInstanceOf(IOException.class)
.hasMessageContaining("timeout");
Mockito.verify(process).destroyForcibly();
}
}
}
@Nested
@DisplayName("runCommandWithOutputHandling - working directory overload")
class WorkingDirectoryTests {
@Test
@DisplayName("the working-directory overload runs the command and applies the directory")
void withWorkingDirectory() throws Exception {
Process process = mockedProcess("ok", "", true, 0);
try (MockedConstruction<ProcessBuilder> construction = stubProcessBuilder(process)) {
ProcessExecutorResult result =
qpdfExecutor()
.runCommandWithOutputHandling(
List.of("qpdf", "--version"),
new java.io.File(System.getProperty("java.io.tmpdir")));
assertThat(result.getRc()).isEqualTo(0);
// directory(...) must have been applied to the single constructed builder.
ProcessBuilder built = construction.constructed().get(0);
Mockito.verify(built).directory(any(java.io.File.class));
}
}
}
}
@@ -1,354 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Gap-coverage tests for {@link RegexPatternUtils}. The existing RegexPatternUtilsTest covers
* caching mechanics; this file exercises the many lazily-built named accessor patterns, the static
* regex string getters, flag-aware cache operations, and the invalid-regex compile path.
*/
class RegexPatternUtilsMoreTest {
private final RegexPatternUtils utils = RegexPatternUtils.getInstance();
@Nested
@DisplayName("static regex string getters")
class StaticRegexTests {
@Test
@DisplayName("whitespace and extension regex strings are returned")
void staticStrings() {
assertEquals("\\s++", RegexPatternUtils.getWhitespaceRegex());
assertEquals("\\.(?:[^.]*+)?$", RegexPatternUtils.getExtensionRegex());
}
@Test
@DisplayName("supported new field types contains the documented set")
void supportedFieldTypes() {
Set<String> types = utils.getSupportedNewFieldTypes();
assertThat(types)
.contains(
"text",
"checkbox",
"combobox",
"listbox",
"radio",
"button",
"signature");
}
}
@Nested
@DisplayName("flag-aware cache operations")
class FlagCacheTests {
@Test
@DisplayName("removeFromCache with flags removes the flagged entry only")
void removeWithFlags() {
String regex = "moreflagcache\\d+";
utils.getPattern(regex, Pattern.CASE_INSENSITIVE);
assertTrue(utils.isCached(regex, Pattern.CASE_INSENSITIVE));
assertTrue(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE));
assertFalse(utils.isCached(regex, Pattern.CASE_INSENSITIVE));
// Removing again returns false.
assertFalse(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE));
}
@Test
@DisplayName("isCached with flags is false for null regex")
void isCachedNullWithFlags() {
assertFalse(utils.isCached(null, Pattern.CASE_INSENSITIVE));
}
@Test
@DisplayName("removeFromCache with flags is false for null regex")
void removeNullWithFlags() {
assertFalse(utils.removeFromCache(null, Pattern.CASE_INSENSITIVE));
}
}
@Nested
@DisplayName("invalid regex compilation")
class InvalidRegexTests {
@Test
@DisplayName("an invalid pattern propagates PatternSyntaxException")
void invalidPattern() {
assertThrows(PatternSyntaxException.class, () -> utils.getPattern("[unclosed"));
}
}
@Nested
@DisplayName("path and filename patterns")
class PathFilenameTests {
@Test
void driveLetterPattern() {
assertTrue(utils.getDriveLetterPattern().matcher("C:\\Users\\x").find());
}
@Test
void leadingSlashesPattern() {
assertTrue(utils.getLeadingSlashesPattern().matcher("//leading").find());
}
@Test
void backslashPattern() {
assertTrue(utils.getBackslashPattern().matcher("a\\b").find());
}
@Test
void filenameSafePattern() {
assertTrue(utils.getFilenameSafePattern().matcher("a!b").find());
}
@Test
void nonAlnumUnderscorePattern() {
assertTrue(utils.getNonAlnumUnderscorePattern().matcher("a-b").find());
assertFalse(utils.getNonAlnumUnderscorePattern().matcher("a_b").find());
}
@Test
void underscoreCollapsePatterns() {
assertTrue(utils.getMultipleUnderscoresPattern().matcher("a__b").find());
assertTrue(utils.getLeadingUnderscoresPattern().matcher("__a").find());
assertTrue(utils.getTrailingUnderscoresPattern().matcher("a__").find());
}
@Test
void uploadDownloadPathPattern() {
assertTrue(utils.getUploadDownloadPathPattern().matcher("/api/UPLOAD/file").matches());
}
}
@Nested
@DisplayName("whitespace, newline and word patterns")
class WhitespaceNewlineTests {
@Test
void whitespaceAndWordSplit() {
assertEquals(2, utils.getWordSplitPattern().split("a b").length);
assertTrue(utils.getWhitespacePattern().matcher("a b").find());
}
@Test
void punctuationPattern() {
assertTrue(utils.getPunctuationPattern().matcher("a!b").find());
}
@Test
void newlineVariants() {
assertTrue(utils.getNewlinesPattern().matcher("a\r\nb").find());
assertTrue(utils.getNewlineSplitPattern().matcher("a\nb").find());
assertTrue(utils.getCarriageReturnPattern().matcher("a\rb").find());
assertTrue(utils.getNewlineCharsPattern().matcher("a\nb").find());
assertTrue(utils.getMultiFormatNewlinePattern().matcher("a\r\nb").find());
assertTrue(utils.getEncodedPayloadNewlinePattern().matcher("a\nb").find());
assertTrue(utils.getLineSeparatorPattern().matcher("a\nb").find());
}
@Test
void escapedNewlinePattern() {
assertTrue(utils.getEscapedNewlinePattern().matcher("line\\nbreak").find());
}
}
@Nested
@DisplayName("sanitization and field-name patterns")
class SanitizationTests {
@Test
void inputSanitizePattern() {
assertTrue(utils.getInputSanitizePattern().matcher("a@b").find());
}
@Test
void formFieldBracketPattern() {
assertEquals(
"field", utils.getFormFieldBracketPattern().matcher("field[0]").replaceAll(""));
}
@Test
void underscoreHyphenPattern() {
assertTrue(utils.getUnderscoreHyphenPattern().matcher("a-_b").find());
}
@Test
void camelCaseBoundaryPattern() {
assertEquals(
"first Name",
utils.getCamelCaseBoundaryPattern().matcher("firstName").replaceAll(" "));
}
@Test
void angleBracketsAndQuotes() {
assertTrue(utils.getAngleBracketsPattern().matcher("a<b>c").find());
assertTrue(utils.getQuotesRemovalPattern().matcher("\"q\"").find());
}
@Test
void plusAndPipe() {
assertTrue(utils.getPlusSignPattern().matcher("a+b").find());
assertEquals(2, utils.getPipeDelimiterPattern().split("a|b").length);
}
@Test
void usernameValidationPattern() {
assertTrue(utils.getUsernameValidationPattern().matcher("john_doe1").matches());
assertFalse(utils.getUsernameValidationPattern().matcher("a--b").matches());
}
@Test
void genericAndSimpleFieldPatterns() {
assertTrue(utils.getGenericFieldNamePattern().matcher("Field 1").matches());
assertTrue(utils.getSimpleFormFieldPattern().matcher("t1").matches());
assertTrue(utils.getOptionalTNumericPattern().matcher("t 12").matches());
}
}
@Nested
@DisplayName("number and math patterns")
class NumberMathTests {
@Test
void numericExtractionAndDigitPatterns() {
assertTrue(utils.getNumericExtractionPattern().matcher("a1").find());
assertTrue(utils.getNonDigitDotPattern().matcher("1a").find());
assertTrue(utils.getDigitDotPattern().matcher("1.0").find());
assertTrue(utils.getContainsDigitsPattern().matcher("ab12cd").matches());
assertTrue(utils.getNumberRangePattern().matcher("250").matches());
}
@Test
void mathExpressionPatterns() {
assertTrue(utils.getMathExpressionPattern().matcher("2n+1").matches());
assertTrue(utils.getNumberBeforeNPattern().matcher("4n").find());
assertTrue(utils.getConsecutiveNPattern().matcher("annb").matches());
assertTrue(utils.getConsecutiveNReplacementPattern().matcher("nn").find());
}
}
@Nested
@DisplayName("url, email and html patterns")
class UrlEmailHtmlTests {
@Test
void httpAndLinkPatterns() {
assertTrue(utils.getHttpUrlPattern().matcher("https://x.com").matches());
assertTrue(utils.getUrlLinkPattern().matcher("see http://x.com/a").find());
assertTrue(utils.getEmailLinkPattern().matcher("a@b.com").find());
}
@Test
void emailValidationPattern() {
assertTrue(utils.getEmailValidationPattern().matcher("user@example.com").matches());
assertFalse(utils.getEmailValidationPattern().matcher("not-an-email").matches());
}
@Test
void scriptStyleAndCssPatterns() {
assertTrue(utils.getScriptTagPattern().matcher("<script>x()</script>").find());
assertTrue(utils.getStyleTagPattern().matcher("<style>a{}</style>").find());
assertTrue(utils.getFixedPositionCssPattern().matcher("position: fixed;").find());
assertTrue(utils.getAbsolutePositionCssPattern().matcher("position: absolute;").find());
}
@Test
void inlineCidAndImagePatterns() {
assertTrue(utils.getInlineCidImagePattern().matcher("<img src=\"cid:abc\">").find());
assertTrue(utils.getImageFilePattern().matcher("photo.JPG").matches());
}
}
@Nested
@DisplayName("size, temp-file and mime patterns")
class SizeTempMimeTests {
@Test
void sizeUnitPattern() {
assertTrue(utils.getSizeUnitPattern().matcher("MB").find());
}
@Test
void systemTempFilePatterns() {
assertTrue(utils.getSystemTempFile1Pattern().matcher("lu123abc.tmp").find());
assertTrue(utils.getSystemTempFile2Pattern().matcher("ocr_process42").find());
}
@Test
void whitespaceParensSplit() {
assertTrue(utils.getWhitespaceParenthesesSplitPattern().matcher("a (b)").find());
}
@Test
void mimeHeaderAndEncodedWord() {
assertTrue(utils.getMimeHeaderWhitespacePattern().matcher("a =?utf-8").find());
assertTrue(utils.getMimeEncodedWordPattern().matcher("=?utf-8?B?abc?=").find());
}
@Test
void fontNamePattern() {
assertTrue(utils.getFontNamePattern().matcher("ABCDEF+Arial").matches());
}
}
@Nested
@DisplayName("xml, attachment and api-doc patterns")
class XmlAttachmentApiTests {
@Test
void accessReadOnlyAndXmpPatterns() {
assertTrue(utils.getAccessReadOnlyPattern().matcher("access=\"readOnly\"").find());
assertTrue(utils.getPdfAidPartPattern().matcher("pdfaid:part=\"2\"").find());
assertTrue(
utils.getPdfAidConformancePattern().matcher("pdfaid:conformance=\"B\"").find());
}
@Test
void attachmentPatterns() {
assertTrue(utils.getAttachmentSectionPattern().matcher("Attachments (3)").find());
assertTrue(utils.getAttachmentFilenamePattern().matcher("@ file.txt").find());
}
@Test
void pageModeAndApiDocPatterns() {
assertTrue(utils.getPageModePattern().matcher("a/b").find());
assertTrue(utils.getApiDocOutputTypePattern().matcher("Output: PDF").find());
assertTrue(utils.getApiDocInputTypePattern().matcher("Input: PDF").find());
assertTrue(utils.getApiDocTypePattern().matcher("Type: WEB").find());
}
@Test
void fileExtensionValidationAndLeadingAsterisks() {
assertTrue(utils.getFileExtensionValidationPattern().matcher("pdf").matches());
assertFalse(utils.getFileExtensionValidationPattern().matcher("a").matches());
assertEquals(
"text",
utils.getLeadingAsterisksWhitespacePattern()
.matcher("** text")
.replaceFirst(""));
}
}
@Test
@DisplayName("every cached accessor returns a non-null pattern")
void accessorsNeverNull() {
assertNotNull(utils.getTrailingSlashesPattern());
assertNotNull(utils.getSafeFilenamePattern());
assertNotNull(utils.getWordSplitPattern());
}
}
@@ -97,51 +97,4 @@ class SvgSanitizerTest {
byte[] invalid = "not xml at all".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitize(invalid));
}
@Test
void testSanitize_removesRootRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"/tmp/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("/tmp/image.png"), "Root-relative local path must be stripped");
}
@Test
void testSanitize_removesRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
}
@Test
void testSanitize_removesRootRelativeWindowsDrivePath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
+ "<image xlink:href=\"/C:/Users/x/external-image.svg\""
+ " width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(
output.contains("external-image"), "Root-relative Windows path must be stripped");
}
@Test
void testSanitize_keepsInDocumentFragmentReference() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<use href=\"#gradient\"/><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertTrue(
output.contains("#gradient"), "In-document fragment references must be preserved");
}
}
@@ -1,203 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
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.io.TempDir;
import org.snakeyaml.engine.v2.api.LoadSettings;
class YamlHelperMoreTest {
private static final LoadSettings LOAD_SETTINGS =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
private YamlHelper helper(String yaml) {
return new YamlHelper(LOAD_SETTINGS, yaml);
}
@Nested
@DisplayName("updateValue value-type handling")
class UpdateValueTypes {
@Test
@DisplayName("updates an integer value with INT tag")
void integerValue() {
YamlHelper h = helper("server:\n port: 80\n");
assertThat(h.updateValue(List.of("server", "port"), 8080)).isTrue();
assertThat(h.getValueByExactKeyPath("server", "port")).isEqualTo("8080");
}
@Test
@DisplayName("updates a float value")
void floatValue() {
YamlHelper h = helper("scale:\n factor: 1.0\n");
assertThat(h.updateValue(List.of("scale", "factor"), 2.5f)).isTrue();
assertThat(String.valueOf(h.getValueByExactKeyPath("scale", "factor")))
.startsWith("2.5");
}
@Test
@DisplayName("updates a boolean value via string literal")
void booleanValue() {
YamlHelper h = helper("flags:\n on: false\n");
assertThat(h.updateValue(List.of("flags", "on"), "true")).isTrue();
assertThat(h.getValueByExactKeyPath("flags", "on")).isEqualTo("true");
}
@Test
@DisplayName("replaces a scalar with a Map value (MappingNode)")
void mapValue() {
YamlHelper h = helper("meta:\n data: placeholder\n");
Map<String, Object> map = new LinkedHashMap<>();
map.put("author", "alice");
map.put("year", 2024);
assertThat(h.updateValue(List.of("meta", "data"), map)).isTrue();
assertThat(h.getValueByExactKeyPath("meta", "data", "author")).isEqualTo("alice");
assertThat(h.getValueByExactKeyPath("meta", "data", "year")).isEqualTo("2024");
}
@Test
@DisplayName("replaces a scalar with a List value (SequenceNode)")
void listValue() {
YamlHelper h = helper("cfg:\n items: x\n");
assertThat(h.updateValue(List.of("cfg", "items"), List.of("a", "b", "c"))).isTrue();
Object value = h.getValueByExactKeyPath("cfg", "items");
assertThat(value).isInstanceOf(List.class);
List<?> list = (List<?>) value;
assertThat(list).hasSize(3);
assertThat(list.toString()).contains("a").contains("b").contains("c");
}
@Test
@DisplayName("list with mixed scalar element types is converted")
void mixedListValue() {
YamlHelper h = helper("cfg:\n vals: x\n");
assertThat(h.updateValue(List.of("cfg", "vals"), List.of("s", 1, 2.5, "true")))
.isTrue();
Object value = h.getValueByExactKeyPath("cfg", "vals");
assertThat((List<?>) value).hasSize(4);
}
@Test
@DisplayName("updates a previously null scalar")
void nullScalarBecomesValue() {
YamlHelper h = helper("opt:\n value:\n");
assertThat(h.updateValue(List.of("opt", "value"), "set")).isTrue();
assertThat(h.getValueByExactKeyPath("opt", "value")).isEqualTo("set");
}
@Test
@DisplayName("updates a null scalar to a boolean (BOOL tag promotion)")
void nullScalarBecomesBoolean() {
YamlHelper h = helper("opt:\n enabled:\n");
assertThat(h.updateValue(List.of("opt", "enabled"), Boolean.TRUE)).isTrue();
assertThat(h.getValueByExactKeyPath("opt", "enabled")).isEqualTo("true");
}
@Test
@DisplayName("returns false when intermediate key path is not a mapping")
void nonMappingPathReturnsFalse() {
YamlHelper h = helper("server:\n port: 80\n");
// 'port' is a scalar, so descending into it cannot update.
assertThat(h.updateValue(List.of("server", "port", "deeper"), "x")).isFalse();
}
}
@Nested
@DisplayName("updateValuesFromYaml")
class UpdateFromYaml {
@Test
@DisplayName("copies differing existing keys from source into target")
void copiesChangedValues() {
YamlHelper target = helper("server:\n port: 80\n host: localhost\n");
YamlHelper source = helper("server:\n port: 9090\n host: localhost\n");
boolean updated = target.updateValuesFromYaml(source, target);
assertThat(updated).isTrue();
assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("9090");
}
@Test
@DisplayName("source keys absent from target are not added (no update)")
void unknownKeysIgnored() {
YamlHelper target = helper("server:\n port: 80\n");
YamlHelper source = helper("server:\n port: 80\n");
boolean updated = target.updateValuesFromYaml(source, target);
assertThat(updated).isFalse();
assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("80");
}
}
@Nested
@DisplayName("save / saveOverride / node tracking")
class SaveAndNodes {
@Test
@DisplayName("save to the original path is a no-op write but returns the mapping")
void saveSamePathNoRewrite(@TempDir Path tempDir) throws IOException {
Path file = tempDir.resolve("orig.yaml");
Files.writeString(file, "a:\n b: 1\n");
YamlHelper h = new YamlHelper(file);
h.updateValue(List.of("a", "b"), 2);
// Same path: method must not rewrite the file but still return a MappingNode.
assertThat(h.save(file)).isNotNull();
}
@Test
@DisplayName("saveOverride writes to disk")
void saveOverrideWrites(@TempDir Path tempDir) throws IOException {
YamlHelper h = helper("a:\n b: 1\n");
h.updateValue(List.of("a", "b"), 42);
Path out = tempDir.resolve("out.yaml");
h.saveOverride(out);
assertThat(Files.readString(out)).contains("42");
}
@Test
@DisplayName("setNewNode then getUpdatedRootNode returns the set node")
void setAndGetNode() {
YamlHelper h = helper("a:\n b: 1\n");
var root = h.getUpdatedRootNode();
h.setNewNode(root);
assertThat(h.getUpdatedRootNode()).isSameAs(root);
}
}
@Nested
@DisplayName("static numeric type checks")
class NumericChecks {
@Test
@DisplayName("isShort / isByte accept Long and parsable strings")
void shortAndByte() {
assertThat(YamlHelper.isShort(5L)).isTrue();
assertThat(YamlHelper.isShort("100")).isTrue();
assertThat(YamlHelper.isShort("notNumeric")).isFalse();
assertThat(YamlHelper.isByte(1L)).isTrue();
assertThat(YamlHelper.isByte("7")).isTrue();
assertThat(YamlHelper.isByte("999999")).isFalse();
}
@Test
@DisplayName("isInteger rejects null and non-numeric, accepts boxed integers")
void integerEdges() {
assertThat(YamlHelper.isInteger(null)).isFalse();
assertThat(YamlHelper.isInteger((byte) 3)).isTrue();
assertThat(YamlHelper.isInteger((short) 9)).isTrue();
}
}
}
@@ -1,256 +0,0 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
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.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.ApplicationProperties;
/**
* Tests for {@link ZipExtractionUtils} that build real in-memory ZIP byte streams and exercise
* detection, flat extraction, nested-ZIP recursion, directory skipping and corrupt-input handling.
* No external process is launched.
*/
class ZipExtractionUtilsTest {
private TempFileManager tempFileManager;
private final List<TempFile> created = new ArrayList<>();
@TempDir Path tempDir;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-zip-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
}
@AfterEach
void tearDown() {
for (TempFile tf : created) {
tf.close();
}
created.clear();
}
// ----- helpers -----------------------------------------------------------
/** Build a flat ZIP from name->bytes entries. */
private static byte[] buildZip(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 byte[] bytes(String s) {
return s.getBytes(StandardCharsets.UTF_8);
}
private static Resource resource(byte[] data) {
return new ByteArrayResource(data);
}
private static String drain(Resource r) throws IOException {
try (InputStream is = r.getInputStream()) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
@Nested
@DisplayName("isZip")
class IsZipTests {
@Test
@DisplayName("real ZIP magic bytes are detected")
void detectsRealZip() throws IOException {
byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("hi")});
assertThat(ZipExtractionUtils.isZip(resource(zip))).isTrue();
}
@Test
@DisplayName("non-ZIP content is rejected")
void rejectsNonZip() throws IOException {
assertThat(ZipExtractionUtils.isZip(resource(bytes("not a zip at all")))).isFalse();
}
@Test
@DisplayName("null resource is not a ZIP")
void nullResource() throws IOException {
assertThat(ZipExtractionUtils.isZip(null)).isFalse();
}
@Test
@DisplayName("content shorter than the magic prefix is not a ZIP")
void tooShort() throws IOException {
assertThat(ZipExtractionUtils.isZip(resource(new byte[] {0x50, 0x4B}))).isFalse();
}
@Test
@DisplayName(".cbz filename is explicitly excluded even with ZIP magic bytes")
void cbzExcluded() throws IOException {
byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")});
assertThat(ZipExtractionUtils.isZip(resource(zip), "comic.cbz")).isFalse();
}
@Test
@DisplayName(".cbz exclusion is case-insensitive")
void cbzExcludedUppercase() throws IOException {
byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")});
assertThat(ZipExtractionUtils.isZip(resource(zip), "COMIC.CBZ")).isFalse();
}
@Test
@DisplayName("a non-cbz filename does not suppress detection")
void nonCbzFilenameStillDetected() throws IOException {
byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("x")});
assertThat(ZipExtractionUtils.isZip(resource(zip), "bundle.zip")).isTrue();
}
@Test
@DisplayName("first four bytes that differ from the magic are rejected")
void wrongMagicBytes() throws IOException {
byte[] data = {0x50, 0x4B, 0x05, 0x06, 0x00, 0x00};
assertThat(ZipExtractionUtils.isZip(resource(data))).isFalse();
}
}
@Nested
@DisplayName("extractZip")
class ExtractZipTests {
@Test
@DisplayName("flat ZIP extracts one resource per file entry with filenames preserved")
void flatExtraction() throws IOException {
byte[] zip =
buildZip(
new String[] {"first.txt", "second.txt"},
new byte[][] {bytes("one"), bytes("two")});
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
assertThat(result).hasSize(2);
assertThat(result)
.extracting(Resource::getFilename)
.containsExactlyInAnyOrder("first.txt", "second.txt");
assertThat(drain(result.get(0)) + drain(result.get(1))).contains("one").contains("two");
}
@Test
@DisplayName("directory entries are skipped")
void directoriesSkipped() throws IOException {
byte[] zip =
buildZip(
new String[] {"dir/", "dir/file.txt"},
new byte[][] {null, bytes("payload")});
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
assertThat(result).hasSize(1);
assertThat(result.get(0).getFilename()).isEqualTo("dir/file.txt");
}
@Test
@DisplayName("empty ZIP yields no resources")
void emptyZip() throws IOException {
byte[] zip = buildZip(new String[] {}, new byte[][] {});
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
assertThat(result).isEmpty();
}
@Test
@DisplayName("nested ZIP entries are recursively expanded")
void nestedExtraction() throws IOException {
byte[] inner =
buildZip(new String[] {"inner.txt"}, new byte[][] {bytes("nested-content")});
byte[] outer =
buildZip(
new String[] {"top.txt", "child.zip"},
new byte[][] {bytes("top-content"), inner});
List<Resource> result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager);
// top.txt + the single file inside child.zip => 2 flat resources
assertThat(result).hasSize(2);
assertThat(result)
.extracting(Resource::getFilename)
.containsExactlyInAnyOrder("top.txt", "inner.txt");
}
@Test
@DisplayName("tempFileConsumer receives every created temp file")
void consumerInvoked() throws IOException {
byte[] zip =
buildZip(
new String[] {"a.txt", "b.txt"}, new byte[][] {bytes("a"), bytes("b")});
List<TempFile> seen = new ArrayList<>();
List<Resource> result =
ZipExtractionUtils.extractZip(
resource(zip),
tempFileManager,
tf -> {
seen.add(tf);
created.add(tf);
});
assertThat(result).hasSize(2);
assertThat(seen).hasSize(2);
}
@Test
@DisplayName("a .cbz entry inside the ZIP is kept as a single file, not recursed")
void cbzEntryNotRecursed() throws IOException {
byte[] innerZip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("imgdata")});
byte[] outer = buildZip(new String[] {"book.cbz"}, new byte[][] {innerZip});
List<Resource> result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager);
assertThat(result).hasSize(1);
assertThat(result.get(0).getFilename()).isEqualTo("book.cbz");
}
@Test
@DisplayName("a truncated ZIP entry stream surfaces as an IOException")
void corruptZip() throws IOException {
// Build a real ZIP with compressible content, then truncate it mid-stream so the
// deflate entry cannot be fully read and extraction fails.
byte[] valid =
buildZip(new String[] {"big.txt"}, new byte[][] {bytes("A".repeat(8192))});
byte[] truncated = new byte[valid.length / 2];
System.arraycopy(valid, 0, truncated, 0, truncated.length);
assertThatThrownBy(
() ->
ZipExtractionUtils.extractZip(
resource(truncated), tempFileManager))
.isInstanceOf(IOException.class);
}
}
}
@@ -1,162 +0,0 @@
package stirling.software.common.util.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
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;
import org.springframework.core.io.InputStreamResource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
/**
* Gap-filling tests for {@link CustomColorReplaceStrategy#replace()} that run the full restyle loop
* against real, tiny PDFs built in-memory with PDFBox. No external process is launched.
*/
class CustomColorReplaceStrategyMoreTest {
/**
* A one-page PDF that draws a line of text so the restyle loop has TextPositions to process.
*/
private static byte[] pdfWithText(String text) throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static byte[] emptyPagePdf() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static MultipartFile pdf(byte[] bytes) {
return new MockMultipartFile("file", "input.pdf", "application/pdf", bytes);
}
private static int pageCount(InputStreamResource resource) throws IOException {
try (InputStream is = resource.getInputStream();
PDDocument doc = Loader.loadPDF(is.readAllBytes())) {
return doc.getNumberOfPages();
}
}
@Nested
@DisplayName("replace - custom colours")
class CustomColourTests {
@Test
@DisplayName("restyles text and overlays a background, returning a valid PDF")
void customColoursProduceValidPdf() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(pdfWithText("Hello World")),
ReplaceAndInvert.CUSTOM_COLOR,
"#000000",
"#FFFFFF",
null);
InputStreamResource result = strategy.replace();
assertThat(result).isNotNull();
assertThat(pageCount(result)).isEqualTo(1);
}
@Test
@DisplayName("a page without any text still gets the background overlay")
void emptyPageStillProcessed() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(emptyPagePdf()),
ReplaceAndInvert.CUSTOM_COLOR,
"#112233",
"#AABBCC",
null);
InputStreamResource result = strategy.replace();
assertThat(pageCount(result)).isEqualTo(1);
}
@Test
@DisplayName("text restyling runs through the font-encoding path without failing")
void fontEncodingPathExercised() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(pdfWithText("Hi there 123")),
ReplaceAndInvert.CUSTOM_COLOR,
"#101010",
"#FFFFFF",
null);
InputStreamResource result = strategy.replace();
assertThat(pageCount(result)).isEqualTo(1);
}
}
@Nested
@DisplayName("replace - high contrast colours")
class HighContrastTests {
@Test
@DisplayName("high-contrast mode resolves colours from the combination and produces a PDF")
void highContrastProducesValidPdf() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(pdfWithText("Contrast")),
ReplaceAndInvert.HIGH_CONTRAST_COLOR,
null,
null,
HighContrastColorCombination.WHITE_TEXT_ON_BLACK);
InputStreamResource result = strategy.replace();
assertThat(pageCount(result)).isEqualTo(1);
}
}
@Nested
@DisplayName("replace - invalid input")
class InvalidInputTests {
@Test
@DisplayName("a non-PDF payload causes replace() to throw")
void nonPdfThrows() {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf("not a pdf".getBytes()),
ReplaceAndInvert.CUSTOM_COLOR,
"000000",
"FFFFFF",
null);
assertThatThrownBy(strategy::replace).isInstanceOf(IOException.class);
}
}
}
+7 -6
View File
@@ -67,7 +67,7 @@ dependencies {
exclude group: 'com.fasterxml.jackson.jaxrs'
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
}
implementation "commons-io:commons-io:$commonsIoVersion"
implementation 'commons-io:commons-io:2.22.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core'
@@ -81,24 +81,25 @@ dependencies {
implementation 'org.verapdf:validation-model:1.28.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation "org.mozilla:rhino:${rhinoVersion}"
implementation 'org.mozilla:rhino:1.9.1'
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}"
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}"
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
@@ -22,7 +22,6 @@ import stirling.software.SPDF.config.InitialSetup;
import stirling.software.SPDF.controller.api.security.TimestampController;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -38,7 +37,6 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final ShowAdminInterface showAdmin;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
@@ -50,8 +48,6 @@ public class ConfigController {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ShowAdminInterface showAdmin,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
@@ -60,7 +56,6 @@ public class ConfigController {
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.showAdmin = showAdmin;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
}
@@ -320,10 +315,6 @@ public class ConfigController {
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().isEnableAlphaFunctionality());
boolean shouldShowUpdate =
applicationProperties.getSystem().isShowUpdate()
&& (showAdmin == null || showAdmin.getShowUpdateOnlyAdmins());
configData.put("shouldShowUpdate", shouldShowUpdate);
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
@@ -45,10 +45,6 @@ public class SvgOverlayUtil {
@Override
public void checkLoadExternalResource(
ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch).
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG overlays: "
+ resourceURL);
@@ -19,7 +19,6 @@ import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent;
import org.apache.batik.bridge.UserAgentAdapter;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.ParsedURL;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -64,7 +63,7 @@ public class SvgToPdf {
}
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
UserAgent userAgent = createSecureUserAgent();
UserAgent userAgent = new UserAgentAdapter();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -95,21 +94,6 @@ public class SvgToPdf {
}
}
private UserAgent createSecureUserAgent() {
return new UserAgentAdapter() {
@Override
public void checkLoadExternalResource(ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch) - allow them.
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG to PDF conversion: "
+ resourceURL);
}
};
}
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
throws IOException {
GVTBuilder builder = new GVTBuilder();
@@ -218,7 +202,7 @@ public class SvgToPdf {
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
}
UserAgent userAgent = createSecureUserAgent();
UserAgent userAgent = new UserAgentAdapter();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -170,8 +170,8 @@ system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showUpdate: false # see when a new update is available
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showSettingsWhenNoLogin: true # set to 'false' to hide settings button when login is disabled (enableLogin: false). Only applies when login is disabled.
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
@@ -1,333 +0,0 @@
package org.apache.pdfbox.examples.signature;
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.Mockito.mock;
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.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URL;
import java.net.URLConnection;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Set;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.DigestCalculator;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampResponseGenerator;
import org.bouncycastle.tsp.TimeStampTokenGenerator;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Exercises the vendored PDFBox {@link TSAClient}. The TSA HTTP boundary is replaced with a mocked
* {@link URL}/{@link URLConnection} so no real network is ever contacted. A real BouncyCastle TSA
* response is generated in-process to drive the success path.
*/
@DisplayName("TSAClient (vendored PDFBox) Tests")
class TSAClientTest {
private static KeyPair tsaKeyPair;
private static X509Certificate tsaCert;
@BeforeAll
static void setUpProviderAndCert() throws Exception {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
tsaKeyPair = kpg.generateKeyPair();
tsaCert = selfSignedCert(tsaKeyPair);
}
private static X509Certificate selfSignedCert(KeyPair kp) throws Exception {
X500Principal dn = new X500Principal("CN=Test TSA");
long now = System.currentTimeMillis();
Date from = new Date(now - 1000L);
Date to = new Date(now + 365L * 24 * 60 * 60 * 1000);
BigInteger serial = BigInteger.valueOf(now);
ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA").build(kp.getPrivate());
JcaX509v3CertificateBuilder builder =
new JcaX509v3CertificateBuilder(dn, serial, from, to, dn, kp.getPublic());
// RFC 3161 requires the TSA signing cert to carry a critical id-kp-timeStamping EKU.
builder.addExtension(
Extension.extendedKeyUsage,
true,
new ExtendedKeyUsage(KeyPurposeId.id_kp_timeStamping));
X509CertificateHolder holder = builder.build(signer);
return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
}
/** Builds a valid RFC 3161 timestamp response matching the supplied request bytes. */
private static byte[] buildTsaResponse(byte[] requestBytes) throws Exception {
TimeStampRequest request = new TimeStampRequest(requestBytes);
// SHA-1 digest calculator for the token's messageImprint of the signer cert.
DigestCalculator sha1 =
new JcaDigestCalculatorProviderBuilder()
.setProvider("BC")
.build()
.get(
new AlgorithmIdentifier(
org.bouncycastle.asn1.oiw.OIWObjectIdentifiers.idSHA1));
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(tsaKeyPair.getPrivate());
TimeStampTokenGenerator tokenGen =
new TimeStampTokenGenerator(
new org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder()
.setProvider("BC")
.build())
.build(signer, tsaCert),
sha1,
new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.2.3.4.1"));
// Embed the signer certificate so the response token validates standalone.
tokenGen.addCertificates(
new org.bouncycastle.cert.jcajce.JcaCertStore(java.util.List.of(tsaCert)));
// Accept the SHA-256 digest used by the request's messageImprint.
Set<String> acceptedAlgorithms =
Set.of(org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256.getId());
TimeStampResponseGenerator responseGen =
new TimeStampResponseGenerator(tokenGen, acceptedAlgorithms);
TimeStampResponse response = responseGen.generate(request, BigInteger.ONE, new Date());
return response.getEncoded();
}
private TSAClient newClient(URL url, String username, String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return new TSAClient(url, username, password, digest);
}
@Nested
@DisplayName("Successful timestamp request")
class SuccessTests {
@Test
@DisplayName("Returns a parsed time stamp token from a valid TSA response")
void returnsTokenOnValidResponse() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
CapturingOutputStream sink = new CapturingOutputStream();
when(connection.getOutputStream()).thenReturn(sink);
// Lazily build the response based on the request actually written to the connection.
ResponseSupplierInputStream responseStream = new ResponseSupplierInputStream(sink);
when(connection.getInputStream()).thenReturn(responseStream);
TSAClient client = newClient(url, null, null);
var token = client.getTimeStampToken(new ByteArrayInputStream("hello pdf".getBytes()));
assertThat(token).isNotNull();
assertThat(token.getTimeStampInfo()).isNotNull();
// Content-Type header is always set for the timestamp query.
verify(connection).setRequestProperty("Content-Type", "application/timestamp-query");
verify(connection).setDoOutput(true);
verify(connection).setDoInput(true);
}
@Test
@DisplayName("Sends a Basic Authorization header when credentials are supplied")
void addsBasicAuthHeaderWhenCredentialsPresent() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
when(connection.getContentEncoding()).thenReturn(null);
CapturingOutputStream sink = new CapturingOutputStream();
when(connection.getOutputStream()).thenReturn(sink);
when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink));
TSAClient client = newClient(url, "user", "secret");
client.getTimeStampToken(new ByteArrayInputStream("data".getBytes()));
verify(connection)
.setRequestProperty(
org.mockito.ArgumentMatchers.eq("Authorization"),
org.mockito.ArgumentMatchers.startsWith("Basic "));
}
@Test
@DisplayName("Does not send Authorization header when username is empty")
void noAuthHeaderWhenUsernameEmpty() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
CapturingOutputStream sink = new CapturingOutputStream();
when(connection.getOutputStream()).thenReturn(sink);
when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink));
TSAClient client = newClient(url, "", "secret");
client.getTimeStampToken(new ByteArrayInputStream("data".getBytes()));
verify(connection, never())
.setRequestProperty(org.mockito.ArgumentMatchers.eq("Authorization"), any());
}
}
@Nested
@DisplayName("Error handling")
class ErrorTests {
@Test
@DisplayName("Propagates IOException raised while writing the request")
void propagatesWriteFailure() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
OutputStream failing =
new OutputStream() {
@Override
public void write(int b) throws IOException {
throw new IOException("write boom");
}
};
when(connection.getOutputStream()).thenReturn(failing);
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class)
.hasMessageContaining("write boom");
}
@Test
@DisplayName("Propagates IOException raised while reading the response")
void propagatesReadFailure() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
when(connection.getOutputStream()).thenReturn(new CapturingOutputStream());
when(connection.getInputStream()).thenThrow(new IOException("read boom"));
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class)
.hasMessageContaining("read boom");
}
@Test
@DisplayName("Wraps a malformed (non-TSP) response as an IOException")
void wrapsMalformedResponse() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
when(connection.getOutputStream()).thenReturn(new CapturingOutputStream());
when(connection.getInputStream())
.thenReturn(new ByteArrayInputStream("not a tsp response".getBytes()));
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class);
}
@Test
@DisplayName("Throws when the connection cannot be opened")
void throwsWhenConnectionFails() throws Exception {
URL url = mock(URL.class);
when(url.openConnection()).thenThrow(new IOException("no route"));
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class)
.hasMessageContaining("no route");
}
}
/** Collects everything written so the matching TSA response can be generated afterward. */
private static final class CapturingOutputStream extends OutputStream {
private final ByteArrayOutputStream delegate = new ByteArrayOutputStream();
@Override
public void write(int b) {
delegate.write(b);
}
@Override
public void write(byte[] b, int off, int len) {
delegate.write(b, off, len);
}
byte[] toByteArray() {
return delegate.toByteArray();
}
}
/** Lazily builds the TSA response from the request captured by the sink on first read. */
private static final class ResponseSupplierInputStream extends java.io.InputStream {
private final CapturingOutputStream sink;
private ByteArrayInputStream delegate;
ResponseSupplierInputStream(CapturingOutputStream sink) {
this.sink = sink;
}
private ByteArrayInputStream delegate() {
if (delegate == null) {
try {
delegate = new ByteArrayInputStream(buildTsaResponse(sink.toByteArray()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return delegate;
}
@Override
public int read() {
return delegate().read();
}
@Override
public int read(byte[] b, int off, int len) {
return delegate().read(b, off, len);
}
}
}
@@ -1,35 +0,0 @@
package org.apache.pdfbox.examples.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link ValidationTimeStamp}. Only the constructor is exercised; it builds a
* TSAClient object without performing any network I/O.
*/
class ValidationTimeStampTest {
@Test
@DisplayName("null tsaUrl leaves the client unset and constructs cleanly")
void nullUrl() throws Exception {
ValidationTimeStamp vts = new ValidationTimeStamp(null);
assertThat(vts).isNotNull();
}
@Test
@DisplayName("valid tsaUrl builds the timestamp client without contacting the network")
void validUrl() throws Exception {
ValidationTimeStamp vts = new ValidationTimeStamp("http://timestamp.example.com/tsa");
assertThat(vts).isNotNull();
}
@Test
@DisplayName("malformed tsaUrl is rejected with an exception")
void malformedUrl() {
assertThatThrownBy(() -> new ValidationTimeStamp("http:// bad host/tsa"))
.isInstanceOf(Exception.class);
}
}
@@ -1,186 +0,0 @@
package stirling.software.SPDF;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
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.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import io.github.pixee.security.SystemCommand;
/**
* Unit tests for {@link LibreOfficeListener}. The process, socket and SystemCommand boundaries are
* mocked so no real soffice/unoconv process or network connection is ever created.
*/
@DisplayName("LibreOfficeListener")
class LibreOfficeListenerTest {
private LibreOfficeListener listener;
@BeforeEach
void setUp() {
listener = LibreOfficeListener.getInstance();
}
@AfterEach
void tearDown() throws Exception {
// Reset the singleton's mutable state so tests stay independent.
ExecutorService es = readExecutor();
if (es != null) {
es.shutdownNow();
}
setField("process", null);
setField("executorService", null);
}
private void setField(String name, Object value) throws Exception {
Field f = LibreOfficeListener.class.getDeclaredField(name);
f.setAccessible(true);
f.set(listener, value);
}
private Object readField(String name) throws Exception {
Field f = LibreOfficeListener.class.getDeclaredField(name);
f.setAccessible(true);
return f.get(listener);
}
private ExecutorService readExecutor() throws Exception {
return (ExecutorService) readField("executorService");
}
@Nested
@DisplayName("getInstance")
class GetInstance {
@Test
@DisplayName("always returns the same singleton instance")
void singletonIsStable() {
assertThat(LibreOfficeListener.getInstance())
.isSameAs(LibreOfficeListener.getInstance());
}
}
@Nested
@DisplayName("start")
class Start {
@Test
@DisplayName("returns immediately when a live process already exists")
void alreadyRunningShortCircuits() throws Exception {
Process alive = Mockito.mock(Process.class);
Mockito.when(alive.isAlive()).thenReturn(true);
setField("process", alive);
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class)) {
listener.start();
// No new process is spawned when one is already alive.
sys.verifyNoInteractions();
assertThat(readField("process")).isSameAs(alive);
assertThat(readExecutor()).isNull();
}
}
@Test
@DisplayName("spawns the listener and returns once the socket connects")
void spawnsAndDetectsRunningListener() throws Exception {
Process spawned = Mockito.mock(Process.class);
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
MockedConstruction<Socket> socket = Mockito.mockConstruction(Socket.class)) {
sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString()))
.thenReturn(spawned);
listener.start();
// The spawned process is retained and the monitor executor is created.
assertThat(readField("process")).isSameAs(spawned);
assertThat(readExecutor()).isNotNull();
// A socket was constructed and connected exactly once on the first poll.
assertThat(socket.constructed()).hasSize(1);
Mockito.verify(socket.constructed().get(0)).connect(any(), eq(1000));
sys.verify(
() ->
SystemCommand.runCommand(
any(Runtime.class), eq("unoconv --listener")));
}
}
@Test
@DisplayName("retries until the listener socket becomes reachable")
void retriesUntilSocketReachable() throws Exception {
Process spawned = Mockito.mock(Process.class);
// First socket fails to connect, second succeeds; start() should poll twice.
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
MockedConstruction<Socket> socket =
Mockito.mockConstruction(
Socket.class,
(mock, ctx) -> {
if (ctx.getCount() == 1) {
Mockito.doThrow(new java.io.IOException("refused"))
.when(mock)
.connect(any(), eq(1000));
}
})) {
sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString()))
.thenReturn(spawned);
listener.start();
// At least two sockets were attempted before one connected.
assertThat(socket.constructed().size()).isGreaterThanOrEqualTo(2);
}
}
}
@Nested
@DisplayName("stop")
class Stop {
@Test
@DisplayName("shuts down the monitor and destroys a live process")
void destroysLiveProcess() throws Exception {
Process alive = Mockito.mock(Process.class);
Mockito.when(alive.isAlive()).thenReturn(true);
ExecutorService es = Mockito.mock(ExecutorService.class);
setField("process", alive);
setField("executorService", es);
listener.stop();
Mockito.verify(es).shutdownNow();
Mockito.verify(alive).destroy();
}
@Test
@DisplayName("does not destroy a process that is already dead")
void skipsDestroyWhenProcessDead() throws Exception {
Process dead = Mockito.mock(Process.class);
Mockito.when(dead.isAlive()).thenReturn(false);
ExecutorService es = Mockito.mock(ExecutorService.class);
setField("process", dead);
setField("executorService", es);
listener.stop();
Mockito.verify(es).shutdownNow();
Mockito.verify(dead, Mockito.never()).destroy();
}
}
}
@@ -1,168 +0,0 @@
package stirling.software.SPDF;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.env.Environment;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Remaining static-helper and lifecycle coverage for {@link SPDFApplication} that the existing
* {@code SPDFApplicationMoreTest} does not reach: profile selection, classpath probing, the
* setServerPortStatic auto/explicit branches and the non-Tauri init path.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SPDFApplication remaining coverage")
class SPDFApplicationExtraTest {
@Mock private AppConfig appConfig;
@Mock private Environment env;
@Mock private ApplicationProperties applicationProperties;
private static Object invokeStatic(String name, Class<?>[] sig, Object... args)
throws Exception {
Method m = SPDFApplication.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(null, args);
}
@Nested
@DisplayName("getActiveProfile")
class GetActiveProfile {
private String[] activeProfile(String[] args) throws Exception {
return (String[])
invokeStatic(
"getActiveProfile", new Class<?>[] {String[].class}, (Object) args);
}
@Test
@DisplayName("explicit --spring.profiles.active wins over classpath detection")
void explicitProfile() throws Exception {
String[] result = activeProfile(new String[] {"--spring.profiles.active=foo,bar"});
assertThat(result).containsExactly("foo", "bar");
}
@Test
@DisplayName("a single explicit profile is honoured")
void singleExplicitProfile() throws Exception {
String[] result = activeProfile(new String[] {"--spring.profiles.active=custom"});
assertThat(result).containsExactly("custom");
}
@Test
@DisplayName("null args fall through to classpath-based detection")
void nullArgsFallThrough() throws Exception {
// Falls through to classpath detection; exact profile depends on the build flavor.
String[] result = activeProfile(null);
assertThat(result).isNotNull().isNotEmpty();
}
@Test
@DisplayName("no profile arg falls through to classpath-based detection")
void noProfileArgFallThrough() throws Exception {
String[] result = activeProfile(new String[] {"--server.port=9090"});
assertThat(result).isNotNull().isNotEmpty();
}
}
@Nested
@DisplayName("isClassPresent")
class IsClassPresent {
private boolean present(String className) throws Exception {
return (boolean)
invokeStatic("isClassPresent", new Class<?>[] {String.class}, className);
}
@Test
@DisplayName("returns true for a class on the classpath")
void existingClass() throws Exception {
assertThat(present("stirling.software.SPDF.SPDFApplication")).isTrue();
}
@Test
@DisplayName("returns false for a missing class")
void missingClass() throws Exception {
assertThat(present("com.example.totally.Missing")).isFalse();
}
}
@Nested
@DisplayName("setServerPortStatic")
class SetServerPortStatic {
@Test
@DisplayName("'auto' maps to Spring's 0 (auto-assign) port")
void autoMapsToZero() {
SPDFApplication.setServerPortStatic("auto");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("0");
}
@Test
@DisplayName("'AUTO' is matched case-insensitively")
void autoCaseInsensitive() {
SPDFApplication.setServerPortStatic("AUTO");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("0");
}
@Test
@DisplayName("an explicit port is stored verbatim")
void explicitPort() {
SPDFApplication.setServerPortStatic("8443");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("8443");
}
}
@Nested
@DisplayName("init (non-Tauri, browser disabled)")
class InitNonTauri {
@Test
@DisplayName("populates the static URL fields without opening a browser")
void initBrowserDisabled() {
System.clearProperty("STIRLING_PDF_TAURI_MODE");
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
when(appConfig.getContextPath()).thenReturn("/app");
when(appConfig.getServerPort()).thenReturn("9000");
when(env.getProperty("BROWSER_OPEN")).thenReturn(null);
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
app.init();
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:9000");
assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/app");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("9000");
}
}
@Test
@DisplayName("getStaticBaseUrl reflects the most recent init")
void staticBaseUrlReflectsInit() {
AppConfig cfg = mock(AppConfig.class);
when(cfg.getBackendUrl()).thenReturn("https://example.org");
when(cfg.getContextPath()).thenReturn("/");
when(cfg.getServerPort()).thenReturn("443");
Environment e = mock(Environment.class);
when(e.getProperty("BROWSER_OPEN")).thenReturn("false");
new SPDFApplication(cfg, e, applicationProperties).init();
// default https port 443 is omitted from the normalized base url
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("https://example.org");
}
}
@@ -1,237 +0,0 @@
package stirling.software.SPDF;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
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.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.core.env.Environment;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
/** Static URL helpers and lifecycle branches of SPDFApplication that need no Spring context. */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SPDFApplication extra coverage")
class SPDFApplicationMoreTest {
private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE";
private static final String BROWSER_OPEN = "BROWSER_OPEN";
@Mock private AppConfig appConfig;
@Mock private Environment env;
@Mock private ApplicationProperties applicationProperties;
private String originalTauri;
@BeforeEach
void setUp() {
originalTauri = System.getProperty(TAURI_PROP);
System.clearProperty(TAURI_PROP);
}
@AfterEach
void tearDown() {
if (originalTauri == null) {
System.clearProperty(TAURI_PROP);
} else {
System.setProperty(TAURI_PROP, originalTauri);
}
}
private static Object invokeStatic(String name, Class<?>[] sig, Object... args)
throws Exception {
Method m = SPDFApplication.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(null, args);
}
@Nested
@DisplayName("normalizeBackendUrl")
class NormalizeBackendUrl {
private String normalize(String url, String port) throws Exception {
return (String)
invokeStatic(
"normalizeBackendUrl",
new Class<?>[] {String.class, String.class},
url,
port);
}
@Test
@DisplayName("blank backend url defaults to localhost with port")
void blankDefaultsLocalhost() throws Exception {
assertThat(normalize("", "8080")).isEqualTo("http://localhost:8080");
}
@Test
@DisplayName("adds scheme when missing and appends non-default port")
void addsSchemeAndPort() throws Exception {
assertThat(normalize("example.com", "9000")).isEqualTo("http://example.com:9000");
}
@Test
@DisplayName("omits default http port 80")
void omitsDefaultHttpPort() throws Exception {
assertThat(normalize("http://example.com", "80")).isEqualTo("http://example.com");
}
@Test
@DisplayName("omits default https port 443")
void omitsDefaultHttpsPort() throws Exception {
assertThat(normalize("https://example.com", "443")).isEqualTo("https://example.com");
}
@Test
@DisplayName("strips trailing slashes")
void stripsTrailingSlash() throws Exception {
assertThat(normalize("http://example.com///", "80")).isEqualTo("http://example.com");
}
@Test
@DisplayName("keeps an explicit port already in the url")
void keepsExplicitPort() throws Exception {
assertThat(normalize("http://example.com:1234", null))
.isEqualTo("http://example.com:1234");
}
}
@Nested
@DisplayName("buildFullUrl")
class BuildFullUrl {
private String build(String base, String port, String ctx) throws Exception {
return (String)
invokeStatic(
"buildFullUrl",
new Class<?>[] {String.class, String.class, String.class},
base,
port,
ctx);
}
@Test
@DisplayName("root context path yields a single trailing slash")
void rootContext() throws Exception {
assertThat(build("http://localhost", "8080", "/")).isEqualTo("http://localhost:8080/");
}
@Test
@DisplayName("non-root context path is prefixed with a slash")
void prefixesContext() throws Exception {
assertThat(build("http://localhost", "8080", "app"))
.isEqualTo("http://localhost:8080/app");
}
@Test
@DisplayName("blank context path treated as root")
void blankContext() throws Exception {
assertThat(build("http://localhost", "8080", "")).isEqualTo("http://localhost:8080/");
}
}
@Nested
@DisplayName("parsePort")
class ParsePort {
private Integer parse(String port) throws Exception {
return (Integer) invokeStatic("parsePort", new Class<?>[] {String.class}, port);
}
@Test
@DisplayName("parses a positive port")
void positive() throws Exception {
assertThat(parse("8080")).isEqualTo(8080);
}
@Test
@DisplayName("null for blank, non-numeric, zero, and negative")
void invalidInputs() throws Exception {
assertThat(parse("")).isNull();
assertThat(parse("abc")).isNull();
assertThat(parse("0")).isNull();
assertThat(parse("-5")).isNull();
}
}
@Nested
@DisplayName("appendPortFallback")
class AppendPortFallback {
private String append(String base, Integer port) throws Exception {
return (String)
invokeStatic(
"appendPortFallback",
new Class<?>[] {String.class, Integer.class},
base,
port);
}
@Test
@DisplayName("null port returns base unchanged")
void nullPort() throws Exception {
assertThat(append("http://host", null)).isEqualTo("http://host");
}
@Test
@DisplayName("base already ending in a port is unchanged")
void alreadyHasPort() throws Exception {
assertThat(append("http://host:1234", 80)).isEqualTo("http://host:1234");
}
@Test
@DisplayName("appends the port otherwise")
void appendsPort() throws Exception {
assertThat(append("http://host", 8080)).isEqualTo("http://host:8080");
}
}
@Nested
@DisplayName("lifecycle")
class Lifecycle {
@Test
@DisplayName("onApplicationReady picks up the runtime local.server.port")
void onApplicationReadyUsesRuntimePort() {
ApplicationReadyEvent event = mock(ApplicationReadyEvent.class, RETURNS_DEEP_STUBS);
when(event.getApplicationContext().getEnvironment().getProperty("local.server.port"))
.thenReturn("44444");
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
app.onApplicationReady(event);
assertThat(SPDFApplication.getStaticPort()).isEqualTo("44444");
}
@Test
@DisplayName("init in Tauri mode logs parent pid and sets static URLs")
void initTauriMode() {
System.setProperty(TAURI_PROP, "true");
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
when(appConfig.getContextPath()).thenReturn("/");
when(appConfig.getServerPort()).thenReturn("8080");
when(env.getProperty(BROWSER_OPEN)).thenReturn("false");
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
app.init();
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:8080");
assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/");
}
}
}
@@ -1,188 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
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 org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@DisplayName("EndpointInspector (additional coverage)")
class EndpointInspectorMoreTest {
private ApplicationContext applicationContext;
private EndpointInspector inspector;
// Simple controller bean providing a handler method for HandlerMethod construction.
static class DummyController {
public String handle() {
return "ok";
}
}
@BeforeEach
void setUp() {
applicationContext = mock(ApplicationContext.class);
inspector = new EndpointInspector(applicationContext);
}
private HandlerMethod handlerMethod() throws Exception {
Method method = DummyController.class.getMethod("handle");
return new HandlerMethod(new DummyController(), method);
}
@SuppressWarnings("unchecked")
private Set<String> validEndpoints() throws Exception {
Field field = EndpointInspector.class.getDeclaredField("validGetEndpoints");
field.setAccessible(true);
return (Set<String>) field.get(inspector);
}
private void stubMapping(Map<RequestMappingInfo, HandlerMethod> handlerMethods) {
RequestMappingHandlerMapping mapping = mock(RequestMappingHandlerMapping.class);
when(mapping.getHandlerMethods()).thenReturn(handlerMethods);
Map<String, RequestMappingHandlerMapping> beans = new HashMap<>();
beans.put("requestMappingHandlerMapping", mapping);
when(applicationContext.getBeansOfType(RequestMappingHandlerMapping.class))
.thenReturn(beans);
}
@Nested
@DisplayName("onApplicationEvent")
class OnApplicationEvent {
@Test
@DisplayName("discovers endpoints exactly once across repeated events")
void discoversOnce() throws Exception {
stubMapping(new LinkedHashMap<>());
ContextRefreshedEvent event = new ContextRefreshedEvent(new StaticApplicationContext());
inspector.onApplicationEvent(event);
inspector.onApplicationEvent(event);
Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
discovered.setAccessible(true);
assertThat(discovered.getBoolean(inspector)).isTrue();
}
}
@Nested
@DisplayName("discoverEndpoints")
class DiscoverEndpoints {
@Test
@DisplayName("collects direct paths from a GET mapping")
void collectsDirectPaths() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo getInfo =
RequestMappingInfo.paths("/dashboard").methods(RequestMethod.GET).build();
methods.put(getInfo, handlerMethod());
stubMapping(methods);
Set<String> endpoints = inspector.getValidGetEndpoints();
assertThat(endpoints).contains("/dashboard");
}
@Test
@DisplayName("treats a mapping with no explicit method as a GET handler")
void noMethodCountsAsGet() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo anyInfo = RequestMappingInfo.paths("/anything").build();
methods.put(anyInfo, handlerMethod());
stubMapping(methods);
assertThat(inspector.getValidGetEndpoints()).contains("/anything");
}
@Test
@DisplayName("ignores non-GET only mappings")
void ignoresPostOnly() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo postInfo =
RequestMappingInfo.paths("/save").methods(RequestMethod.POST).build();
methods.put(postInfo, handlerMethod());
stubMapping(methods);
assertThat(inspector.getValidGetEndpoints()).doesNotContain("/save");
}
@Test
@DisplayName("falls back to string parsing for pattern-only mappings")
void fallsBackToStringParsing() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
// Wildcard patterns are not direct paths, forcing the toString() fallback branch.
RequestMappingInfo patternInfo =
RequestMappingInfo.paths("/files/**").methods(RequestMethod.GET).build();
methods.put(patternInfo, handlerMethod());
stubMapping(methods);
Set<String> endpoints = inspector.getValidGetEndpoints();
assertThat(endpoints).anySatisfy(p -> assertThat(p).contains("/files"));
}
}
@Nested
@DisplayName("getValidGetEndpoints")
class GetValidGetEndpoints {
@Test
@DisplayName("triggers discovery when not yet discovered")
void triggersDiscovery() throws Exception {
stubMapping(new LinkedHashMap<>());
Set<String> endpoints = inspector.getValidGetEndpoints();
// Empty discovery installs the fallback set.
assertThat(endpoints).contains("/", "/**", "/api/**");
}
}
@Nested
@DisplayName("matching helpers")
class MatchingHelpers {
@Test
@DisplayName("matchesPathSegments rejects a URI shorter than the pattern")
void shorterUriDoesNotMatch() throws Exception {
validEndpoints().clear();
validEndpoints().add("/api/v1/convert");
markDiscovered();
assertThat(inspector.isValidGetEndpoint("/api")).isFalse();
}
@Test
@DisplayName("wildcard with a star prefix matches the static portion")
void starPrefixMatches() throws Exception {
validEndpoints().clear();
validEndpoints().add("/static/*");
markDiscovered();
assertThat(inspector.isValidGetEndpoint("/static/app.js")).isTrue();
}
private void markDiscovered() throws Exception {
Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
discovered.setAccessible(true);
discovered.setBoolean(inspector, true);
}
}
}
@@ -1,244 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
/**
* Pure-helper coverage for {@link ExternalAppDepConfig}: the inner Version comparator, the
* weasyprint/qpdf command recognisers, feature-name formatting and the findFirstAvailable probe
* loop. No real binaries run; ProcessBuilder is intercepted where a probe is required.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ExternalAppDepConfig helper coverage")
class ExternalAppDepConfigExtraTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private RuntimePathConfig runtimePathConfig;
private ExternalAppDepConfig config;
@BeforeEach
void setUp() {
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/opt/weasyprint");
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/opt/unoconvert");
when(runtimePathConfig.getCalibrePath()).thenReturn("/opt/calibre");
when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/opt/ocrmypdf");
when(runtimePathConfig.getSOfficePath()).thenReturn("/opt/soffice");
lenient()
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
.thenReturn(Set.of());
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
}
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(config, args);
}
private static Process processReturning(int exitCode, String stdout) {
Process p = mock(Process.class);
try {
doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class));
} catch (InterruptedException ignored) {
// mock never throws
}
doReturn(exitCode).when(p).exitValue();
doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)))
.when(p)
.getInputStream();
doAnswer(inv -> new ByteArrayInputStream(new byte[0])).when(p).getErrorStream();
return p;
}
@Nested
@DisplayName("Version comparator")
class VersionComparator {
private Comparable<Object> version(String v) throws Exception {
Class<?> versionClass =
Class.forName("stirling.software.SPDF.config.ExternalAppDepConfig$Version");
Constructor<?> ctor = versionClass.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
@SuppressWarnings("unchecked")
Comparable<Object> instance = (Comparable<Object>) ctor.newInstance(v);
return instance;
}
@Test
@DisplayName("orders by major, then minor, then patch")
void ordersBySegments() throws Exception {
assertThat(version("1.2.3").compareTo(version("1.2.4"))).isNegative();
assertThat(version("2.0.0").compareTo(version("1.9.9"))).isPositive();
assertThat(version("1.2.0").compareTo(version("1.2"))).isZero();
}
@Test
@DisplayName("treats equal versions as equal")
void equalVersions() throws Exception {
assertThat(version("58.0").compareTo(version("58.0.0"))).isZero();
}
@Test
@DisplayName("non-numeric segments are treated as zero")
void nonNumericSegmentsAsZero() throws Exception {
// "12.x" -> 12.0.0, equal to "12"
assertThat(version("12.x").compareTo(version("12"))).isZero();
}
@Test
@DisplayName("toString renders the three-segment form")
void toStringThreeSegments() throws Exception {
assertThat(version("11.9").toString()).isEqualTo("11.9.0");
}
}
@Nested
@DisplayName("command recognisers")
class CommandRecognisers {
private boolean isWeasyprint(String command) throws Exception {
return (boolean) invoke("isWeasyprint", new Class<?>[] {String.class}, command);
}
private boolean isQpdf(String command) throws Exception {
return (boolean) invoke("isQpdf", new Class<?>[] {String.class}, command);
}
@Test
@DisplayName("isWeasyprint matches the configured path and any name containing weasyprint")
void weasyprintMatches() throws Exception {
assertThat(isWeasyprint("/opt/weasyprint")).isTrue();
assertThat(isWeasyprint("WeasyPrint")).isTrue();
assertThat(isWeasyprint("gs")).isFalse();
}
@Test
@DisplayName("isQpdf matches any name containing qpdf, case-insensitively")
void qpdfMatches() throws Exception {
assertThat(isQpdf("qpdf")).isTrue();
assertThat(isQpdf("/usr/bin/QPDF")).isTrue();
assertThat(isQpdf("tesseract")).isFalse();
}
}
@Nested
@DisplayName("feature-name formatting")
class FeatureFormatting {
private String capitalizeWord(String word) throws Exception {
return (String) invoke("capitalizeWord", new Class<?>[] {String.class}, word);
}
private String formatEndpointAsFeature(String endpoint) throws Exception {
return (String)
invoke("formatEndpointAsFeature", new Class<?>[] {String.class}, endpoint);
}
@Test
@DisplayName("capitalizeWord upper-cases the first letter and lowers the rest")
void capitalizes() throws Exception {
assertThat(capitalizeWord("hello")).isEqualTo("Hello");
assertThat(capitalizeWord("WORLD")).isEqualTo("World");
}
@Test
@DisplayName("capitalizeWord keeps pdf fully upper-cased")
void capitalizesPdf() throws Exception {
assertThat(capitalizeWord("pdf")).isEqualTo("PDF");
assertThat(capitalizeWord("PDF")).isEqualTo("PDF");
}
@Test
@DisplayName("capitalizeWord returns null/empty unchanged")
void capitalizeEmpty() throws Exception {
assertThat(capitalizeWord("")).isEmpty();
assertThat(capitalizeWord((String) null)).isNull();
}
@Test
@DisplayName("formatEndpointAsFeature humanises a hyphenated endpoint with pdf/img mapping")
void humanisesEndpoint() throws Exception {
// "pdf-to-img" -> tokens pdf,to,image -> "PDF To Image"
assertThat(formatEndpointAsFeature("pdf-to-img")).isEqualTo("PDF To Image");
}
@Test
@DisplayName("formatEndpointAsFeature title-cases a plain endpoint")
void titleCasesPlain() throws Exception {
assertThat(formatEndpointAsFeature("merge-pdfs")).contains("Merge");
}
}
@Nested
@DisplayName("findFirstAvailable")
class FindFirstAvailable {
@SuppressWarnings("unchecked")
private Optional<String> findFirstAvailable(List<String> commands) throws Exception {
return (Optional<String>)
invoke("findFirstAvailable", new Class<?>[] {List.class}, commands);
}
@Test
@DisplayName("returns the first command whose lookup probe succeeds")
void returnsFirstSuccessful() throws Exception {
// python3 lookup fails (exit 1), python lookup succeeds (exit 0)
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) -> {
List<?> cmd = (List<?>) ctx.arguments().get(0);
boolean python = cmd.contains("python");
doReturn(processReturning(python ? 0 : 1, "")).when(pbMock).start();
})) {
Optional<String> result = findFirstAvailable(List.of("python3", "python"));
assertThat(result).contains("python");
}
}
@Test
@DisplayName("returns empty when no command is available")
void emptyWhenNoneAvailable() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) ->
doReturn(processReturning(1, "")).when(pbMock).start())) {
Optional<String> result = findFirstAvailable(List.of("nope1", "nope2"));
assertThat(result).isEmpty();
}
}
}
}
@@ -1,315 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
/**
* Covers the process-probing paths of ExternalAppDepConfig by intercepting ProcessBuilder
* construction. No real external binaries are ever executed. Process mocks are wired with
* doReturn/doAnswer to avoid nested-when stubbing inside the mockConstruction initializer.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ExternalAppDepConfig extra coverage")
class ExternalAppDepConfigMoreTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private RuntimePathConfig runtimePathConfig;
private ExternalAppDepConfig config;
@BeforeEach
void setUp() {
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/custom/weasyprint");
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
when(runtimePathConfig.getCalibrePath()).thenReturn("/custom/calibre");
when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/custom/ocrmypdf");
when(runtimePathConfig.getSOfficePath()).thenReturn("/custom/soffice");
lenient()
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
.thenReturn(Set.of());
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
}
/**
* Build a Process mock that finishes with the given exit code and stream text. Uses doReturn so
* it can be safely called inside a mockConstruction initializer.
*/
private static Process processReturning(int exitCode, String stdout, String stderr) {
Process p = mock(Process.class);
try {
doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class));
} catch (InterruptedException ignored) {
// mock never actually throws
}
doReturn(exitCode).when(p).exitValue();
// Fresh stream per call so concurrent/repeated reads never race on a consumed buffer.
doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)))
.when(p)
.getInputStream();
doAnswer(inv -> new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8)))
.when(p)
.getErrorStream();
return p;
}
/** Make every ProcessBuilder built in scope return the supplied process from start(). */
private static MockedConstruction<ProcessBuilder> alwaysReturn(Process p) {
return mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) -> {
try {
doReturn(p).when(pbMock).start();
} catch (Exception ignored) {
}
});
}
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(config, args);
}
@Nested
@DisplayName("isCommandAvailable")
class IsCommandAvailable {
@Test
@DisplayName("true when OS lookup (where/which) exits 0")
void availableViaLookup() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) {
boolean available =
(boolean) invoke("isCommandAvailable", new Class<?>[] {String.class}, "gs");
assertThat(available).isTrue();
}
}
@Test
@DisplayName("falls back to --version when lookup fails, then true")
void availableViaVersionFallback() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) -> {
List<?> cmd = (List<?>) ctx.arguments().get(0);
boolean isVersion = cmd.contains("--version");
doReturn(processReturning(isVersion ? 0 : 1, "1.0", ""))
.when(pbMock)
.start();
})) {
boolean available =
(boolean)
invoke(
"isCommandAvailable",
new Class<?>[] {String.class},
"weirdcmd");
assertThat(available).isTrue();
}
}
@Test
@DisplayName("false when both lookup and --version fail")
void unavailable() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(1, "", ""))) {
boolean available =
(boolean)
invoke("isCommandAvailable", new Class<?>[] {String.class}, "nope");
assertThat(available).isFalse();
}
}
}
@Nested
@DisplayName("getVersionSafe")
class GetVersionSafe {
@Test
@DisplayName("extracts a version number from combined output")
@SuppressWarnings("unchecked")
void extractsVersion() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "qpdf version 11.9.0", ""))) {
Optional<String> version =
(Optional<String>)
invoke(
"getVersionSafe",
new Class<?>[] {String.class, String.class},
"qpdf",
"--version");
assertThat(version).contains("11.9.0");
}
}
@Test
@DisplayName("empty when command exits non-zero")
@SuppressWarnings("unchecked")
void emptyOnNonZero() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(2, "", ""))) {
Optional<String> version =
(Optional<String>)
invoke(
"getVersionSafe",
new Class<?>[] {String.class, String.class},
"qpdf",
"--version");
assertThat(version).isEmpty();
}
}
}
@Nested
@DisplayName("runAndWait")
class RunAndWait {
@Test
@DisplayName("returns timeout code 124 and destroys the process when it does not finish")
void timeoutDestroysProcess() throws Exception {
Process p = mock(Process.class);
doReturn(false).when(p).waitFor(anyLong(), any(TimeUnit.class));
try (MockedConstruction<ProcessBuilder> ignored = alwaysReturn(p)) {
Object result =
invoke(
"runAndWait",
new Class<?>[] {List.class, Duration.class},
List.of("sleep", "100"),
Duration.ofMillis(10));
Method ec = result.getClass().getDeclaredMethod("exitCode");
ec.setAccessible(true);
assertThat((int) ec.invoke(result)).isEqualTo(124);
verify(p).destroyForcibly();
}
}
@Test
@DisplayName("returns code 127 when ProcessBuilder.start throws IOException")
void ioExceptionYields127() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) ->
when(pbMock.start())
.thenThrow(new java.io.IOException("cannot run")))) {
Object result =
invoke(
"runAndWait",
new Class<?>[] {List.class, Duration.class},
List.of("bogus"),
Duration.ofSeconds(1));
Method ec = result.getClass().getDeclaredMethod("exitCode");
ec.setAccessible(true);
assertThat((int) ec.invoke(result)).isEqualTo(127);
}
}
}
/**
* checkDependencyAndDisableGroup is tested directly (single-threaded). The public
* checkDependencies() fans probes out over virtual threads where Mockito's thread-confined
* MockedConstruction would not intercept, so it is not exercised here.
*/
@Nested
@DisplayName("checkDependencyAndDisableGroup")
class CheckDependencyAndDisableGroup {
@Test
@DisplayName("disables the affected group when the command is missing")
void disablesMissingGroup() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(1, "", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "gs");
}
verify(endpointConfiguration)
.disableGroup("Ghostscript", EndpointConfiguration.DisableReason.DEPENDENCY);
}
@Test
@DisplayName("present command with no version gate leaves the group enabled")
void presentCommandNotDisabled() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "gs");
}
verify(endpointConfiguration, never())
.disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY));
}
@Test
@DisplayName("qpdf below the required version disables the qpdf group")
void qpdfBelowMinimumDisabled() throws Exception {
// Lookup succeeds (exit 0) and --version reports an old release -> version gate fires.
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "qpdf version 10.0.0", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "qpdf");
}
verify(endpointConfiguration)
.disableGroup("qpdf", EndpointConfiguration.DisableReason.DEPENDENCY);
}
@Test
@DisplayName("qpdf at or above the required version stays enabled")
void qpdfMeetsMinimumNotDisabled() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "qpdf version 12.5.0", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "qpdf");
}
verify(endpointConfiguration, never())
.disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY));
}
@Test
@DisplayName("weasyprint below the required version disables the weasyprint group")
void weasyprintBelowMinimumDisabled() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "WeasyPrint 50.0", ""))) {
invoke(
"checkDependencyAndDisableGroup",
new Class<?>[] {String.class},
"/custom/weasyprint");
}
verify(endpointConfiguration)
.disableGroup("Weasyprint", EndpointConfiguration.DisableReason.DEPENDENCY);
}
}
}
@@ -1,182 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
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.MockedStatic;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
class InitialSetupTest {
private ApplicationProperties applicationProperties;
private ApplicationProperties.AutomaticallyGenerated autoGen;
private ApplicationProperties.Legal legal;
private InitialSetup initialSetup;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
autoGen = applicationProperties.getAutomaticallyGenerated();
legal = applicationProperties.getLegal();
initialSetup = new InitialSetup(applicationProperties);
}
@Nested
@DisplayName("initUUIDKey")
class UuidKey {
@Test
@DisplayName("generates and persists a UUID when missing")
void generatesWhenMissing() throws Exception {
autoGen.setUUID(null);
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false);
initialSetup.initUUIDKey();
assertThat(autoGen.getUUID()).isNotBlank();
util.verify(
() ->
GeneralUtils.saveKeyToSettings(
eq("AutomaticallyGenerated.UUID"), any()),
times(1));
}
}
@Test
@DisplayName("keeps an existing valid UUID")
void keepsValid() throws Exception {
autoGen.setUUID("existing");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID("existing")).thenReturn(true);
initialSetup.initUUIDKey();
assertThat(autoGen.getUUID()).isEqualTo("existing");
util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
}
}
}
@Nested
@DisplayName("initSecretKey")
class SecretKey {
@Test
@DisplayName("generates a key when invalid")
void generatesWhenInvalid() throws Exception {
autoGen.setKey(null);
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false);
initialSetup.initSecretKey();
assertThat(autoGen.getKey()).isNotBlank();
util.verify(
() ->
GeneralUtils.saveKeyToSettings(
eq("AutomaticallyGenerated.key"), any()),
times(1));
}
}
@Test
@DisplayName("keeps an existing valid key")
void keepsValid() throws Exception {
autoGen.setKey("secret");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID("secret")).thenReturn(true);
initialSetup.initSecretKey();
assertThat(autoGen.getKey()).isEqualTo("secret");
}
}
}
@Nested
@DisplayName("initLegalUrls")
class LegalUrls {
@Test
@DisplayName("sets defaults when both URLs are empty")
void setsDefaults() throws Exception {
legal.setTermsAndConditions(null);
legal.setPrivacyPolicy("");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initLegalUrls();
assertThat(legal.getTermsAndConditions()).contains("stirlingpdf.com/terms");
assertThat(legal.getPrivacyPolicy()).contains("privacy-policy");
util.verify(
() -> GeneralUtils.saveKeyToSettings(eq("legal.termsAndConditions"), any()),
times(1));
util.verify(
() -> GeneralUtils.saveKeyToSettings(eq("legal.privacyPolicy"), any()),
times(1));
}
}
@Test
@DisplayName("preserves already-configured URLs")
void preservesExisting() throws Exception {
legal.setTermsAndConditions("https://example.com/t");
legal.setPrivacyPolicy("https://example.com/p");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initLegalUrls();
assertThat(legal.getTermsAndConditions()).isEqualTo("https://example.com/t");
util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
}
}
}
@Nested
@DisplayName("initSetAppVersion")
class AppVersion {
@Test
@DisplayName("flags new server when version is missing")
void newServerWhenMissing() throws Exception {
autoGen.setAppVersion(null);
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initSetAppVersion();
assertThat(autoGen.getIsNewServer()).isTrue();
assertThat(autoGen.getAppVersion()).isNotNull();
}
}
@Test
@DisplayName("flags new server when version is 0.0.0")
void newServerWhenZero() throws Exception {
autoGen.setAppVersion("0.0.0");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initSetAppVersion();
assertThat(autoGen.getIsNewServer()).isTrue();
}
}
@Test
@DisplayName("existing server keeps not-new flag")
void existingServer() throws Exception {
autoGen.setAppVersion("1.2.3");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initSetAppVersion();
assertThat(autoGen.getIsNewServer()).isFalse();
}
}
}
}
@@ -1,86 +0,0 @@
package stirling.software.SPDF.config;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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 io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
class MetricsFilterTest {
private SimpleMeterRegistry registry;
private MetricsFilter filter;
private HttpServletRequest request;
private HttpServletResponse response;
private FilterChain chain;
@BeforeEach
void setUp() {
registry = new SimpleMeterRegistry();
filter = new MetricsFilter(registry);
request = mock(HttpServletRequest.class);
response = mock(HttpServletResponse.class);
chain = mock(FilterChain.class);
}
@Nested
@DisplayName("trackable requests")
class Trackable {
@Test
@DisplayName("increments a counter for a trackable URI with session")
void countsWithSession() throws Exception {
HttpSession session = mock(HttpSession.class);
when(session.getId()).thenReturn("sess-1");
when(request.getRequestURI()).thenReturn("/api/v1/general/rotate-pdf");
when(request.getContextPath()).thenReturn("");
when(request.getMethod()).thenReturn("POST");
when(request.getSession(false)).thenReturn(session);
filter.doFilterInternal(request, response, chain);
verify(chain).doFilter(request, response);
}
@Test
@DisplayName("uses no-session tag when session is absent")
void countsWithoutSession() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/merge-pdfs");
when(request.getContextPath()).thenReturn("");
when(request.getMethod()).thenReturn("POST");
when(request.getSession(false)).thenReturn(null);
filter.doFilterInternal(request, response, chain);
verify(chain).doFilter(request, response);
}
}
@Nested
@DisplayName("non-trackable requests")
class NonTrackable {
@Test
@DisplayName("static resource is not counted but chain continues")
void staticResourceSkipped() throws Exception {
when(request.getRequestURI()).thenReturn("/css/style.css");
when(request.getContextPath()).thenReturn("");
filter.doFilterInternal(request, response, chain);
verify(chain).doFilter(request, response);
verify(request, never()).getMethod();
}
}
}
@@ -1,62 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
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 jakarta.servlet.MultipartConfigElement;
import stirling.software.SPDF.controller.web.UploadLimitService;
class MultipartConfigurationTest {
private UploadLimitService uploadLimitService;
private MultipartConfiguration configuration;
@BeforeEach
void setUp() throws Exception {
// Manually constructed config with a mocked service, so Spring env overrides do not apply.
uploadLimitService = mock(UploadLimitService.class);
configuration = new MultipartConfiguration();
Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService");
field.setAccessible(true);
field.set(configuration, uploadLimitService);
}
@Nested
@DisplayName("multipartConfigElement")
class ConfigElement {
@Test
@DisplayName("uses the configured upload limit when positive")
void usesConfiguredLimit() {
long limit = 50L * 1024 * 1024;
when(uploadLimitService.getUploadLimit()).thenReturn(limit);
when(uploadLimitService.getReadableUploadLimit()).thenReturn("50.0 MB");
MultipartConfigElement element = configuration.multipartConfigElement();
assertThat(element.getMaxFileSize()).isEqualTo(limit);
assertThat(element.getMaxRequestSize()).isEqualTo(limit);
}
@Test
@DisplayName("falls back to 2000MB default when no limit configured")
void usesDefaultWhenZero() {
when(uploadLimitService.getUploadLimit()).thenReturn(0L);
MultipartConfigElement element = configuration.multipartConfigElement();
long expectedDefault = 2000L * 1024 * 1024;
assertThat(element.getMaxFileSize()).isEqualTo(expectedDefault);
assertThat(element.getMaxRequestSize()).isEqualTo(expectedDefault);
}
}
}
@@ -1,149 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
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.springdoc.core.customizers.OpenApiCustomizer;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.media.ComposedSchema;
import io.swagger.v3.oas.models.media.Schema;
import stirling.software.common.model.ApplicationProperties;
@DisplayName("OpenApiConfig")
class OpenApiConfigTest {
private ApplicationProperties applicationProperties;
private OpenApiConfig config;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
config = new OpenApiConfig(applicationProperties);
}
@Nested
@DisplayName("customOpenAPI")
class CustomOpenAPI {
@Test
@DisplayName("builds OpenAPI with title, version and 3.0.3 spec")
void buildsBaseOpenApi() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI).isNotNull();
assertThat(openAPI.getOpenapi()).isEqualTo("3.0.3");
assertThat(openAPI.getInfo()).isNotNull();
assertThat(openAPI.getInfo().getTitle()).isEqualTo("Stirling PDF API");
// Version falls back to 1.0.0 when no implementation version on the package.
assertThat(openAPI.getInfo().getVersion()).isNotBlank();
}
@Test
@DisplayName("sets license, contact and terms of service")
void setsLicenseAndContact() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getInfo().getLicense()).isNotNull();
assertThat(openAPI.getInfo().getLicense().getName()).contains("MIT");
assertThat(openAPI.getInfo().getTermsOfService())
.isEqualTo("https://www.stirlingpdf.com/terms");
assertThat(openAPI.getInfo().getContact()).isNotNull();
assertThat(openAPI.getInfo().getContact().getEmail())
.isEqualTo("contact@stirlingpdf.com");
}
@Test
@DisplayName("registers the global AI tag")
void registersAiTag() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getTags()).isNotNull();
assertThat(openAPI.getTags())
.anySatisfy(tag -> assertThat(tag.getName()).isEqualTo("AI"));
}
@Test
@DisplayName("adds a server item and an ErrorResponse schema")
void addsServerAndErrorSchema() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getServers()).isNotEmpty();
assertThat(openAPI.getServers().get(0).getUrl()).isNotBlank();
Components components = openAPI.getComponents();
assertThat(components).isNotNull();
assertThat(components.getSchemas()).containsKey("ErrorResponse");
Schema<?> errorSchema = components.getSchemas().get("ErrorResponse");
assertThat(errorSchema.getProperties())
.containsKeys("timestamp", "status", "error", "message", "path");
}
@Test
@DisplayName("uses relative server URL when SWAGGER_SERVER_URL env var is absent")
void usesRelativeServerWhenEnvAbsent() {
// The test JVM does not set SWAGGER_SERVER_URL so the relative branch runs.
if (System.getenv("SWAGGER_SERVER_URL") == null) {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getServers().get(0).getUrl()).isEqualTo("/");
assertThat(openAPI.getServers().get(0).getDescription())
.isEqualTo("Current Server");
}
}
@Test
@DisplayName("omits API-key security scheme when login is disabled")
void noSecuritySchemeWhenLoginDisabled() {
applicationProperties.getSecurity().setEnableLogin(false);
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getComponents().getSecuritySchemes()).isNullOrEmpty();
assertThat(openAPI.getSecurity()).isNullOrEmpty();
}
@Test
@DisplayName("adds API-key security scheme when login is enabled")
void addsSecuritySchemeWhenLoginEnabled() {
applicationProperties.getSecurity().setEnableLogin(true);
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getComponents().getSecuritySchemes()).containsKey("apiKey");
assertThat(openAPI.getSecurity()).isNotEmpty();
assertThat(openAPI.getSecurity().get(0)).containsKey("apiKey");
}
}
@Nested
@DisplayName("pdfFileOneOfCustomizer")
class PdfFileOneOfCustomizer {
@Test
@DisplayName("replaces PDFFile schema with a oneOf and registers upload/ref shapes")
void replacesPdfFileSchema() {
OpenApiCustomizer customizer = config.pdfFileOneOfCustomizer();
assertThat(customizer).isNotNull();
// Seed an OpenAPI with an existing PDFFile schema to be replaced.
OpenAPI openApi = new OpenAPI().components(new Components());
openApi.getComponents().addSchemas("PDFFile", new Schema<>().type("string"));
customizer.customise(openApi);
var schemas = openApi.getComponents().getSchemas();
assertThat(schemas).containsKeys("PDFFileUpload", "PDFFileRef", "PDFFile");
assertThat(schemas.get("PDFFile")).isInstanceOf(ComposedSchema.class);
ComposedSchema oneOf = (ComposedSchema) schemas.get("PDFFile");
assertThat(oneOf.getOneOf()).hasSize(2);
assertThat(schemas.get("PDFFileUpload").getRequired()).contains("fileInput");
assertThat(schemas.get("PDFFileRef").getRequired()).contains("fileId");
}
}
}
@@ -1,119 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
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.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.models.GroupedOpenApi;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
@DisplayName("SpringDocConfig")
class SpringDocConfigTest {
private SpringDocConfig config;
@BeforeEach
void setUp() {
config = new SpringDocConfig();
}
// Applies every registered customizer against a fresh OpenAPI carrying an Info instance.
private OpenAPI applyCustomizers(GroupedOpenApi api) {
OpenAPI openApi = new OpenAPI().info(new Info());
for (OpenApiCustomizer customizer : api.getOpenApiCustomizers()) {
customizer.customise(openApi);
}
return openApi;
}
@Nested
@DisplayName("pdfProcessingApi")
class PdfProcessingApi {
@Test
@DisplayName("builds the file-processing group with match and exclude paths")
void buildsGroup() {
OpenApiCustomizer pdfFileOneOfCustomizer = openApi -> {};
GroupedOpenApi api = config.pdfProcessingApi(pdfFileOneOfCustomizer);
assertThat(api).isNotNull();
assertThat(api.getGroup()).isEqualTo("file-processing");
assertThat(api.getDisplayName()).isEqualTo("File Processing");
assertThat(api.getPathsToMatch()).contains("/api/v1/**");
assertThat(api.getPathsToExclude()).contains("/api/v1/admin/**", "/api/v1/auth/**");
// The injected oneOf customizer plus the inline info customizer are both registered.
assertThat(api.getOpenApiCustomizers()).hasSizeGreaterThanOrEqualTo(2);
}
@Test
@DisplayName("info customizer sets the processing title and description")
void infoCustomizerSetsTitle() {
GroupedOpenApi api = config.pdfProcessingApi(openApi -> {});
OpenAPI openApi = applyCustomizers(api);
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Processing API");
assertThat(openApi.getInfo().getDescription()).contains("PDF");
}
}
@Nested
@DisplayName("adminApi")
class AdminApi {
@Test
@DisplayName("builds the management group with admin/user/auth paths")
void buildsGroup() {
GroupedOpenApi api = config.adminApi();
assertThat(api.getGroup()).isEqualTo("management");
assertThat(api.getDisplayName()).isEqualTo("Management");
assertThat(api.getPathsToMatch())
.contains("/api/v1/admin/**", "/api/v1/user/**", "/api/v1/auth/**");
}
@Test
@DisplayName("info customizer sets the management title")
void infoCustomizerSetsTitle() {
GroupedOpenApi api = config.adminApi();
OpenAPI openApi = applyCustomizers(api);
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Management API");
assertThat(openApi.getInfo().getDescription()).isNotBlank();
}
}
@Nested
@DisplayName("systemApi")
class SystemApi {
@Test
@DisplayName("builds the system group with ui-data/info paths")
void buildsGroup() {
GroupedOpenApi api = config.systemApi();
assertThat(api.getGroup()).isEqualTo("system");
assertThat(api.getDisplayName()).isEqualTo("System & UI API");
assertThat(api.getPathsToMatch())
.contains("/api/v1/ui-data/**", "/api/v1/info/**", "/api/v1/general/job/**");
}
@Test
@DisplayName("info customizer sets the system title")
void infoCustomizerSetsTitle() {
GroupedOpenApi api = config.systemApi();
OpenAPI openApi = applyCustomizers(api);
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - System API");
assertThat(openApi.getInfo().getDescription()).isNotBlank();
}
}
}
@@ -1,28 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.event.ContextRefreshedEvent;
class StartupApplicationListenerTest {
@Test
@DisplayName("onApplicationEvent records a startup time")
void recordsStartTime() {
StartupApplicationListener listener = new StartupApplicationListener();
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
listener.onApplicationEvent(new ContextRefreshedEvent(new EmptyContext()));
assertThat(StartupApplicationListener.startTime).isNotNull();
assertThat(StartupApplicationListener.startTime).isAfterOrEqualTo(before);
}
// Minimal ApplicationContext to satisfy ContextRefreshedEvent construction.
private static class EmptyContext
extends org.springframework.context.support.StaticApplicationContext {}
}
@@ -1,283 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Optional;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
@DisplayName("TauriProcessMonitor")
class TauriProcessMonitorTest {
private static Object invokePrivate(TauriProcessMonitor monitor, String name, Object... args)
throws Exception {
Method method = findMethod(name);
method.setAccessible(true);
return method.invoke(monitor, args);
}
private static Method findMethod(String name) {
for (Method m : TauriProcessMonitor.class.getDeclaredMethods()) {
if (m.getName().equals(name)) {
return m;
}
}
throw new IllegalStateException("Method not found: " + name);
}
private static void setField(TauriProcessMonitor monitor, String name, Object value)
throws Exception {
Field field = TauriProcessMonitor.class.getDeclaredField(name);
field.setAccessible(true);
field.set(monitor, value);
}
private static Object getField(TauriProcessMonitor monitor, String name) throws Exception {
Field field = TauriProcessMonitor.class.getDeclaredField(name);
field.setAccessible(true);
return field.get(monitor);
}
@Nested
@DisplayName("getCurrentProcessId")
class CurrentProcessId {
@Test
@DisplayName("returns a non-blank PID string")
void returnsPid() {
assertThat(TauriProcessMonitor.getCurrentProcessId()).isNotBlank();
}
}
@Nested
@DisplayName("init")
class Init {
// System.getenv cannot be mocked (java.base), so init() is exercised against the real
// environment, which has no TAURI_PARENT_PID; the present-PID path is driven directly
// through startMonitoring().
@Test
@DisplayName("startMonitoring flips monitoring on and creates a scheduler")
void startMonitoringSchedulesTask() throws Exception {
ApplicationContext ctx = mock(ApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "parentProcessId", "12345");
try {
invokePrivate(monitor, "startMonitoring");
assertThat((Boolean) getField(monitor, "monitoring")).isTrue();
assertThat(getField(monitor, "scheduler")).isNotNull();
assertThat((String) getField(monitor, "parentProcessId")).isEqualTo("12345");
} finally {
// Stop the scheduler thread created by startMonitoring.
monitor.cleanup();
}
}
}
@Nested
@DisplayName("isProcessAlive")
class IsProcessAlive {
@Test
@DisplayName("returns true when ProcessHandle reports the PID present")
void aliveWhenPresent() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L))
.thenReturn(Optional.of(mock(ProcessHandle.class)));
Object result = invokePrivate(monitor, "isProcessAlive", "999");
assertThat((Boolean) result).isTrue();
}
}
@Test
@DisplayName("returns false when ProcessHandle reports the PID absent")
void deadWhenAbsent() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty());
Object result = invokePrivate(monitor, "isProcessAlive", "999");
assertThat((Boolean) result).isFalse();
}
}
@Test
@DisplayName("returns false for a non-numeric PID")
void falseForInvalidPid() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
Object result = invokePrivate(monitor, "isProcessAlive", "not-a-number");
assertThat((Boolean) result).isFalse();
}
}
@Nested
@DisplayName("checkParentProcess")
class CheckParentProcess {
@Test
@DisplayName("returns early when monitoring is off")
void earlyReturnWhenNotMonitoring() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
setField(monitor, "monitoring", false);
// Should not throw even though parentProcessId is null.
invokePrivate(monitor, "checkParentProcess");
}
@Test
@DisplayName("triggers graceful shutdown when the parent process is dead")
void shutsDownWhenParentDead() throws Exception {
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "monitoring", true);
setField(monitor, "parentProcessId", "999");
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty());
invokePrivate(monitor, "checkParentProcess");
// initiateGracefulShutdown flips monitoring off and spawns an async close.
// The async close runs after a hardcoded 1s sleep, so we assert only the
// immediate, deterministic effect to keep the test fast.
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
}
@Test
@DisplayName("does nothing when the parent process is still alive")
void noShutdownWhenParentAlive() throws Exception {
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "monitoring", true);
setField(monitor, "parentProcessId", "999");
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L))
.thenReturn(Optional.of(mock(ProcessHandle.class)));
invokePrivate(monitor, "checkParentProcess");
assertThat((Boolean) getField(monitor, "monitoring")).isTrue();
}
verify(ctx, never()).close();
}
}
@Nested
@DisplayName("initiateGracefulShutdown")
class InitiateGracefulShutdown {
@Test
@DisplayName("closes a ConfigurableApplicationContext asynchronously")
void closesConfigurableContext() throws Exception {
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "monitoring", true);
invokePrivate(monitor, "initiateGracefulShutdown");
// The async close runs after a hardcoded 1s sleep; assert only the immediate effect.
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
}
@Nested
@DisplayName("cleanup")
class Cleanup {
@Test
@DisplayName("is a no-op when no scheduler was created")
void noOpWithoutScheduler() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
// scheduler is null by default; cleanup must not throw.
monitor.cleanup();
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
@Test
@DisplayName("shuts down an active scheduler")
void shutsDownActiveScheduler() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(false);
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(true);
setField(monitor, "scheduler", scheduler);
setField(monitor, "monitoring", true);
monitor.cleanup();
verify(scheduler, times(1)).shutdown();
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
@Test
@DisplayName("forces shutdownNow when awaitTermination times out")
void forcesShutdownNowOnTimeout() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(false);
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(false);
setField(monitor, "scheduler", scheduler);
monitor.cleanup();
verify(scheduler, times(1)).shutdown();
verify(scheduler, times(1)).shutdownNow();
}
@Test
@DisplayName("restores interrupt flag when awaitTermination is interrupted")
void handlesInterruptedException() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(false);
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS)))
.thenThrow(new InterruptedException("boom"));
setField(monitor, "scheduler", scheduler);
monitor.cleanup();
verify(scheduler, times(1)).shutdownNow();
// Clear the interrupt flag we just set so it does not leak to other tests.
assertThat(Thread.interrupted()).isTrue();
}
@Test
@DisplayName("skips shutdown when scheduler already terminated")
void skipsAlreadyShutdownScheduler() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(true);
setField(monitor, "scheduler", scheduler);
monitor.cleanup();
verify(scheduler, never()).shutdown();
}
}
}
@@ -1,91 +0,0 @@
package stirling.software.SPDF.config;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
class WAUTrackingFilterTest {
private WeeklyActiveUsersService wauService;
private WAUTrackingFilter filter;
private jakarta.servlet.ServletResponse response;
private FilterChain chain;
@BeforeEach
void setUp() {
wauService = mock(WeeklyActiveUsersService.class);
filter = new WAUTrackingFilter(wauService);
response = mock(jakarta.servlet.ServletResponse.class);
chain = mock(FilterChain.class);
}
@Nested
@DisplayName("browser id handling")
class BrowserId {
@Test
@DisplayName("records access when header present")
void recordsWhenPresent() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Browser-Id")).thenReturn("browser-42");
filter.doFilter(request, response, chain);
verify(wauService).recordBrowserAccess("browser-42");
verify(chain).doFilter(request, response);
}
@Test
@DisplayName("does not record when header is null")
void skipsWhenNull() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Browser-Id")).thenReturn(null);
filter.doFilter(request, response, chain);
verify(wauService, never()).recordBrowserAccess(anyString());
verify(chain).doFilter(request, response);
}
@Test
@DisplayName("does not record when header is blank")
void skipsWhenBlank() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Browser-Id")).thenReturn(" ");
filter.doFilter(request, response, chain);
verify(wauService, never()).recordBrowserAccess(anyString());
verify(chain).doFilter(request, response);
}
}
@Nested
@DisplayName("non-http requests")
class NonHttp {
@Test
@DisplayName("passes through without recording")
void nonHttpPassThrough() throws Exception {
ServletRequest request = mock(ServletRequest.class);
filter.doFilter(request, response, chain);
verify(wauService, never()).recordBrowserAccess(anyString());
verify(chain).doFilter(request, response);
}
}
}
@@ -1,202 +0,0 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
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.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.web.servlet.config.annotation.CorsRegistration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import stirling.software.common.model.ApplicationProperties;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("WebMvcConfig")
class WebMvcConfigTest {
private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE";
@Mock private EndpointInterceptor endpointInterceptor;
@Mock private PdfMetricsInterceptor pdfMetricsInterceptor;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System system;
private WebMvcConfig config;
private String originalTauriProp;
@BeforeEach
void setUp() {
originalTauriProp = System.getProperty(TAURI_PROP);
System.clearProperty(TAURI_PROP);
config =
new WebMvcConfig(endpointInterceptor, pdfMetricsInterceptor, applicationProperties);
}
@AfterEach
void tearDown() {
if (originalTauriProp == null) {
System.clearProperty(TAURI_PROP);
} else {
System.setProperty(TAURI_PROP, originalTauriProp);
}
}
@Nested
@DisplayName("addInterceptors")
class AddInterceptors {
@Test
@DisplayName("registers both interceptors in order")
void registersBothInterceptors() {
InterceptorRegistry registry = mock(InterceptorRegistry.class);
InterceptorRegistration registration = mock(InterceptorRegistration.class);
when(registry.addInterceptor(any())).thenReturn(registration);
config.addInterceptors(registry);
verify(registry).addInterceptor(endpointInterceptor);
verify(registry).addInterceptor(pdfMetricsInterceptor);
}
}
@Nested
@DisplayName("addResourceHandlers")
class AddResourceHandlers {
@Test
@DisplayName("registers all five resource handler groups")
void registersFiveHandlerGroups() {
ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class);
ResourceHandlerRegistration registration =
mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS);
when(registry.addResourceHandler(any(String[].class))).thenReturn(registration);
config.addResourceHandlers(registry);
// SW/PWA, assets, media+fonts, branding, catch-all = 5 handler registrations.
verify(registry, times(5)).addResourceHandler(any(String[].class));
}
@Test
@DisplayName("includes the SPA catch-all and assets patterns")
void includesKnownPatterns() {
ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class);
ResourceHandlerRegistration registration =
mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS);
when(registry.addResourceHandler(any(String[].class))).thenReturn(registration);
config.addResourceHandlers(registry);
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
verify(registry, atLeastOnce()).addResourceHandler(captor.capture());
List<String> allPatterns =
captor.getAllValues().stream().flatMap(java.util.Arrays::stream).toList();
assertThat(allPatterns).contains("/**", "/assets/**", "/sw.js");
}
}
@Nested
@DisplayName("addCorsMappings")
class AddCorsMappings {
private CorsRegistry registry;
private CorsRegistration registration;
@BeforeEach
void initRegistry() {
registry = mock(CorsRegistry.class);
registration = mock(CorsRegistration.class, RETURNS_DEEP_STUBS);
when(registry.addMapping(anyString())).thenReturn(registration);
}
@Test
@DisplayName("Tauri mode adds a mapping with Tauri origin patterns")
void tauriModeBranch() {
System.setProperty(TAURI_PROP, "true");
// hasConfiguredOrigins is evaluated before the Tauri check, so getSystem() is
// consulted.
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins()).thenReturn(List.of());
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
@Test
@DisplayName("uses configured origins and appends Tauri origins when present")
void configuredOriginsBranch() {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins())
.thenReturn(new java.util.ArrayList<>(List.of("https://app.example.com")));
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
// origins consulted twice (presence check + value use)
verify(system, atLeastOnce()).getCorsAllowedOrigins();
}
@Test
@DisplayName("configured origins keep an already-present Tauri origin unduplicated")
void configuredOriginsAlreadyContainTauri() {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins())
.thenReturn(
new java.util.ArrayList<>(
List.of(
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")));
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
@Test
@DisplayName("default branch allows all origins when nothing configured")
void defaultBranchAllowsAll() {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins()).thenReturn(List.of());
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
@Test
@DisplayName("default branch also triggers when system is null")
void defaultBranchWhenSystemNull() {
lenient().when(applicationProperties.getSystem()).thenReturn(null);
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
}
}
@@ -1,225 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional branch coverage for {@link CropController}: the Ghostscript routing decision (enabled
* vs disabled when removeDataOutsideCrop is set), the Ghostscript execution path with the external
* process mocked, and the large-image sampling-step branch of detectContentBounds. The gs binary is
* never invoked.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("CropController additional branch tests")
class CropControllerMoreTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private CropController cropController;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile(
tempDir, "crop", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
return tf;
});
}
private MockMultipartFile pdf(int pages) throws IOException {
Path p = tempDir.resolve("crop-src.pdf");
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.LETTER));
}
doc.save(p.toFile());
}
return new MockMultipartFile(
"fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(p));
}
private CropPdfForm form(MockMultipartFile file, boolean removeOutside) {
CropPdfForm f = new CropPdfForm();
f.setFileInput(file);
f.setX(20f);
f.setY(20f);
f.setWidth(200f);
f.setHeight(300f);
f.setAutoCrop(false);
f.setRemoveDataOutsideCrop(removeOutside);
return f;
}
@Nested
@DisplayName("Ghostscript routing")
class GhostscriptRouting {
@Test
@DisplayName(
"removeDataOutsideCrop with Ghostscript disabled falls back to the PDFBox path")
void disabledFallsBackToPdfBox() throws Exception {
MockMultipartFile file = pdf(1);
CropPdfForm request = form(file, true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
PDDocument source = mock(PDDocument.class);
PDDocument out = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(source);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)).thenReturn(out);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// PDFBox path constructs a new document; gs path would not.
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(source);
verify(source).close();
verify(out).close();
}
@Test
@DisplayName("removeDataOutsideCrop with Ghostscript enabled runs the gs command path")
void enabledRunsGhostscript() throws Exception {
MockMultipartFile file = pdf(2);
CropPdfForm request = form(file, true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
// Real document so setCropBox + save() succeed inside the gs branch.
when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes()));
ProcessExecutor executor = mock(ProcessExecutor.class);
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any())).thenReturn(null);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(executor).runCommandWithOutputHandling(any());
}
}
@Test
@DisplayName("Ghostscript interruption is wrapped and surfaced")
void interruptedIsWrapped() throws Exception {
MockMultipartFile file = pdf(1);
CropPdfForm request = form(file, true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes()));
ProcessExecutor executor = mock(ProcessExecutor.class);
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any()))
.thenThrow(new InterruptedException("stop"));
org.junit.jupiter.api.Assertions.assertThrows(
Exception.class, () -> cropController.cropPdf(request));
}
}
}
@Nested
@DisplayName("detectContentBounds sampling step")
class SamplingStep {
private Method detect;
@BeforeEach
void setUp() throws Exception {
detect =
CropController.class.getDeclaredMethod(
"detectContentBounds", BufferedImage.class);
detect.setAccessible(true);
}
@Test
@DisplayName("large images (>2000px) use a step of 2 and still locate content")
void largeImageUsesStep2() throws Exception {
// Width > 2000 triggers the step=2 sampling branch.
BufferedImage image = new BufferedImage(2100, 50, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < 2100; x++) {
for (int y = 0; y < 50; y++) {
image.setRGB(x, y, 0xFFFFFF);
}
}
// Dark block on even coordinates so the step-2 scan can see it.
for (int x = 1000; x < 1040; x += 2) {
for (int y = 10; y < 30; y += 2) {
image.setRGB(x, y, 0x000000);
}
}
int[] bounds = (int[]) detect.invoke(null, image);
assertThat(bounds).hasSize(4);
assertThat(bounds[0]).isGreaterThanOrEqualTo(0);
assertThat(bounds[2]).isGreaterThan(bounds[0]);
}
@Test
@DisplayName("zero-size image returns degenerate bounds")
void zeroSizeImage() throws Exception {
BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
image.setRGB(0, 0, 0xFFFFFF);
int[] bounds = (int[]) detect.invoke(null, image);
assertThat(bounds).containsExactly(0, 0, 0, 0);
}
}
}
@@ -1,173 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
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.http.MediaType;
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;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Extra coverage for {@link MergeController} helpers that the mock-based and end-to-end suites do
* not reach directly: the PDFBox {@code addTableOfContents} outline builder and {@code
* mergeDocuments} page-copy path, both driven over real in-memory documents.
*/
class MergeControllerExtraTest {
private CustomPDFDocumentFactory pdfDocumentFactory;
private MergeController mergeController;
@BeforeEach
void setUp() {
pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
TempFileManager tempFileManager =
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
mergeController = new MergeController(pdfDocumentFactory, tempFileManager);
}
private static byte[] pdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
private static MockMultipartFile pdf(String name, int pages) throws IOException {
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, pdfBytes(pages));
}
private void addTableOfContents(PDDocument merged, MultipartFile[] files) throws Exception {
Method m =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
m.setAccessible(true);
m.invoke(mergeController, merged, files);
}
@Nested
@DisplayName("mergeDocuments")
class MergeDocuments {
@Test
@DisplayName("copies all pages from every source into a single document")
void copiesAllPages() throws Exception {
try (PDDocument a = loadPdf(pdfBytes(2));
PDDocument b = loadPdf(pdfBytes(3))) {
try (PDDocument merged = mergeController.mergeDocuments(List.of(a, b))) {
assertThat(merged.getNumberOfPages()).isEqualTo(5);
}
}
}
@Test
@DisplayName("an empty source list yields an empty merged document")
void emptyListEmptyDoc() throws Exception {
try (PDDocument merged = mergeController.mergeDocuments(List.of())) {
assertThat(merged.getNumberOfPages()).isZero();
}
}
@Test
@DisplayName("a single source is copied verbatim")
void singleSource() throws Exception {
try (PDDocument only = loadPdf(pdfBytes(4))) {
try (PDDocument merged = mergeController.mergeDocuments(List.of(only))) {
assertThat(merged.getNumberOfPages()).isEqualTo(4);
}
}
}
private PDDocument loadPdf(byte[] bytes) throws IOException {
return org.apache.pdfbox.Loader.loadPDF(bytes);
}
}
@Nested
@DisplayName("addTableOfContents")
class AddTableOfContents {
@Test
@DisplayName("adds one outline entry per input file titled by filename without extension")
void oneEntryPerFile() throws Exception {
MultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)};
try (PDDocument merged = new PDDocument()) {
for (int i = 0; i < 3; i++) {
merged.addPage(new PDPage(PDRectangle.A4));
}
addTableOfContents(merged, files);
PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline();
assertThat(outline).isNotNull();
List<String> titles = outlineTitles(outline);
assertThat(titles).containsExactly("intro", "body");
}
}
@Test
@DisplayName("outline destinations advance by each source's page count")
void destinationsAdvance() throws Exception {
MultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 1)};
try (PDDocument merged = new PDDocument()) {
merged.addPage(new PDPage(PDRectangle.A4));
merged.addPage(new PDPage(PDRectangle.A4));
addTableOfContents(merged, files);
PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline();
Iterator<PDOutlineItem> it = outline.children().iterator();
// both items resolve and the outline is non-empty
assertThat(it.hasNext()).isTrue();
}
}
@Test
@DisplayName("handles a single-file table of contents")
void singleFileToc() throws Exception {
MultipartFile[] files = {pdf("solo.pdf", 1)};
try (PDDocument merged = new PDDocument()) {
merged.addPage(new PDPage(PDRectangle.A4));
addTableOfContents(merged, files);
assertThat(outlineTitles(merged.getDocumentCatalog().getDocumentOutline()))
.containsExactly("solo");
}
}
private List<String> outlineTitles(PDDocumentOutline outline) {
List<String> titles = new ArrayList<>();
for (PDOutlineItem item : outline.children()) {
titles.add(item.getTitle());
}
return titles;
}
}
}
@@ -1,365 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Calendar;
import java.util.GregorianCalendar;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
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.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
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.ValueSource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* End-to-end coverage for {@link MergeController#mergePdfs} using real in-memory PDFs and the real
* JPDFium merge pipeline plus a real {@link CustomPDFDocumentFactory}/{@link TempFileManager}.
* Exercises the sort modes, the fileOrder reordering branch, table-of-contents generation, the
* removeCertSign signature pre-check, single/empty file handling and the corrupted-input path that
* the mock-based {@code MergeControllerTest}/{@code MergeControllerGapTest} do not reach.
*/
class MergeControllerMoreTest {
private CustomPDFDocumentFactory pdfDocumentFactory;
private TempFileManager tempFileManager;
private MergeController mergeController;
@BeforeEach
void setUp() {
pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
mergeController = new MergeController(pdfDocumentFactory, tempFileManager);
}
// ---- helpers ------------------------------------------------------------
private static byte[] buildPdf(int pageCount, String title, Long modMillis) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pageCount; i++) {
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 720);
cs.showText("Body " + (i + 1));
cs.endText();
}
}
PDDocumentInformation info = document.getDocumentInformation();
if (title != null) {
info.setTitle(title);
}
if (modMillis != null) {
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(modMillis);
info.setModificationDate(cal);
}
document.save(baos);
return baos.toByteArray();
}
}
private static MockMultipartFile pdf(String name, int pages) throws IOException {
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, buildPdf(pages, null, null));
}
private static MockMultipartFile pdf(String name, int pages, String title, Long modMillis)
throws IOException {
return new MockMultipartFile(
"fileInput",
name,
MediaType.APPLICATION_PDF_VALUE,
buildPdf(pages, title, modMillis));
}
private static MergePdfsRequest request(
MockMultipartFile[] files, String sortType, boolean removeCertSign, boolean toc) {
MergePdfsRequest req = new MergePdfsRequest();
req.setFileInput(files);
req.setSortType(sortType);
req.setRemoveCertSign(removeCertSign);
req.setGenerateToc(toc);
return req;
}
private static PDDocument readResponse(ResponseEntity<Resource> response) throws IOException {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
byte[] out;
try (InputStream is = response.getBody().getInputStream()) {
out = is.readAllBytes();
}
assertThat(out.length).isGreaterThan(0);
return Loader.loadPDF(out);
}
@Nested
@DisplayName("Basic merge")
class BasicMerge {
@Test
@DisplayName("merges two PDFs and sums the page counts")
void mergesTwoFiles() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 3)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(5);
}
}
@Test
@DisplayName("merges three PDFs preserving total pages")
void mergesThreeFiles() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 2), pdf("c.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("merging a single file returns its pages")
void mergesSingleFile() throws Exception {
MockMultipartFile[] files = {pdf("solo.pdf", 4)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("null fileInput is treated as an empty set and still returns OK")
void nullFileInput() throws Exception {
MergePdfsRequest req = new MergePdfsRequest();
req.setFileInput(null);
req.setSortType("orderProvided");
req.setRemoveCertSign(false);
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("empty file array returns an empty (zero-byte) body")
void emptyFileArray() throws Exception {
ResponseEntity<Resource> response =
mergeController.mergePdfs(
request(new MockMultipartFile[0], "orderProvided", false, false), null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@Nested
@DisplayName("Sort modes")
class SortModes {
@ParameterizedTest
@ValueSource(
strings = {
"orderProvided",
"byFileName",
"byDateModified",
"byDateCreated",
"byPDFTitle",
"unknownSortType"
})
@DisplayName("every sort mode produces a valid merged document")
void allSortModes(String sortType) throws Exception {
MockMultipartFile[] files = {
pdf("charlie.pdf", 1, "Gamma", 3_000L),
pdf("alpha.pdf", 1, "Alpha", 1_000L),
pdf("bravo.pdf", 1, "Beta", 2_000L)
};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, sortType, false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
@Test
@DisplayName("byFileName orders the first output filename deterministically")
void byFileNameUsesFirstAlphabetical() throws Exception {
MockMultipartFile[] files = {pdf("zebra.pdf", 1), pdf("apple.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "byFileName", false, false), null);
String disposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
// apple.pdf sorts first so it seeds the generated merged filename
assertThat(disposition).contains("apple");
}
}
@Nested
@DisplayName("fileOrder reordering")
class FileOrder {
@Test
@DisplayName("fileOrder param overrides sortType and drives the merge order")
void fileOrderOverridesSort() throws Exception {
MockMultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 2)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(
request(files, "byFileName", false, false), "second.pdf\nfirst.pdf");
String disposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
assertThat(disposition).contains("second");
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
@Test
@DisplayName("blank fileOrder falls through to the sortType branch")
void blankFileOrderUsesSort() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), " ");
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
}
@Nested
@DisplayName("Table of contents and bookmarks")
class TableOfContents {
@Test
@DisplayName("generateToc adds a document outline keyed by filename")
void generatesToc() throws Exception {
MockMultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, true), null);
try (PDDocument result = readResponse(response)) {
PDDocumentOutline outline = result.getDocumentCatalog().getDocumentOutline();
assertThat(outline).isNotNull();
assertThat(outline.children().iterator().hasNext()).isTrue();
}
}
@Test
@DisplayName("a blank filename falls back to a generated Document N title")
void tocBlankFilenameFallback() throws Exception {
MockMultipartFile blankName =
new MockMultipartFile(
"fileInput",
"",
MediaType.APPLICATION_PDF_VALUE,
buildPdf(1, null, null));
MockMultipartFile[] files = {blankName, pdf("named.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, true), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
}
@Nested
@DisplayName("removeCertSign branch")
class RemoveCertSign {
@Test
@DisplayName("removeCertSign with no signatures skips the flatten pass but still merges")
void removeCertSignNoSignatures() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", true, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
@Test
@DisplayName("removeCertSign=false copies the merged output directly")
void removeCertSignFalse() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
}
@Nested
@DisplayName("Corrupted input handling")
class CorruptedInput {
// Drives the PDF pre-validate loop and the merge error path. JPDFium may either reject the
// garbage payload (throw) or salvage a degenerate document; both outcomes are acceptable,
// so
// we only assert the code path runs and any thrown error is an Exception (logged +
// rethrown).
@Test
@DisplayName("a non-PDF payload exercises the pre-validate and merge error branch")
void corruptedPayloadHandled() throws Exception {
MockMultipartFile good = pdf("good.pdf", 1);
MockMultipartFile bad =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
"this is not a pdf at all".getBytes());
MergePdfsRequest req =
request(new MockMultipartFile[] {good, bad}, "orderProvided", false, false);
try {
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
} catch (Exception expected) {
assertThat(expected).isInstanceOf(Exception.class);
}
}
@Test
@DisplayName("an entirely empty payload still runs through the merge pipeline")
void emptyPayloadHandled() throws Exception {
MockMultipartFile empty =
new MockMultipartFile(
"fileInput", "empty.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[0]);
MergePdfsRequest req =
request(new MockMultipartFile[] {empty}, "orderProvided", false, false);
try {
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
} catch (Exception expected) {
assertThat(expected).isInstanceOf(Exception.class);
}
}
}
}
@@ -1,260 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.nio.file.Files;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Branch coverage for {@link MultiPageLayoutController#mergeMultiplePagesIntoOne} options:
* orientation/arrangement/reading-direction validation, margin validation, landscape, RTL and
* by-column layouts, all exercised against real multi-page source documents.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("MultiPageLayoutController options")
class MultiPageLayoutControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private MultiPageLayoutController controller;
@BeforeEach
void setUp() throws Exception {
controller = new MultiPageLayoutController(pdfDocumentFactory, tempFileManager);
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("mpl", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
/** Wires the factory to load real source pages and return a real target document. */
private void wireDocuments(int pages) throws Exception {
PDDocument source = new PDDocument();
for (int i = 0; i < pages; i++) {
source.addPage(new PDPage(PDRectangle.A4));
}
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
.thenReturn(source);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source))
.thenReturn(new PDDocument());
}
private static MockMultipartFile file() {
return new MockMultipartFile("fileInput", "in.pdf", "application/pdf", new byte[] {1});
}
private static MergeMultiplePagesRequest base() {
MergeMultiplePagesRequest req = new MergeMultiplePagesRequest();
req.setFileInput(file());
req.setPagesPerSheet(4);
return req;
}
@Nested
@DisplayName("validation failures")
class Validation {
@Test
@DisplayName("unknown mode is rejected")
void unknownModeThrows() {
MergeMultiplePagesRequest req = base();
req.setMode("WEIRD");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("custom mode with non-positive rows/cols is rejected")
void customNonPositiveThrows() {
MergeMultiplePagesRequest req = base();
req.setMode("CUSTOM");
req.setRows(0);
req.setCols(2);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("invalid orientation is rejected")
void invalidOrientationThrows() {
MergeMultiplePagesRequest req = base();
req.setOrientation("DIAGONAL");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("invalid arrangement is rejected")
void invalidArrangementThrows() {
MergeMultiplePagesRequest req = base();
req.setArrangement("SPIRAL");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("invalid reading direction is rejected")
void invalidReadingDirectionThrows() {
MergeMultiplePagesRequest req = base();
req.setReadingDirection("DIAGONAL");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("negative margins are rejected")
void negativeMarginsThrows() {
MergeMultiplePagesRequest req = base();
req.setTopMargin(-1);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("outer margins that consume the whole page yield a non-positive cell error")
void outerMarginsTooLargeThrows() throws Exception {
wireDocuments(1);
MergeMultiplePagesRequest req = base();
// A4 width is ~595pt; 600 left margin alone makes cell width non-positive.
req.setLeftMargin(600);
req.setRightMargin(600);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("inner margin larger than the cell yields a non-positive inner-area error")
void innerMarginTooLargeThrows() throws Exception {
wireDocuments(1);
MergeMultiplePagesRequest req = base();
req.setInnerMargin(1000);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
}
@Nested
@DisplayName("layout option branches")
class LayoutOptions {
@Test
@DisplayName("landscape orientation succeeds and skips form copying")
void landscapeSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setOrientation("LANDSCAPE");
req.setAddBorder(Boolean.TRUE);
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody().contentLength() >= 0);
}
@Test
@DisplayName("right-to-left reading direction succeeds")
void rtlSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setReadingDirection("RTL");
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("by-columns arrangement with RTL succeeds")
void byColumnsRtlSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setArrangement("BY_COLUMNS");
req.setReadingDirection("RTL");
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("by-columns arrangement with LTR succeeds")
void byColumnsLtrSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setArrangement("BY_COLUMNS");
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("custom mode with explicit borderWidth succeeds")
void customModeWithBorderSucceeds() throws Exception {
wireDocuments(6);
MergeMultiplePagesRequest req = base();
req.setMode("CUSTOM");
req.setRows(2);
req.setCols(3);
req.setAddBorder(Boolean.TRUE);
req.setBorderWidth(3);
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("blank mode string defaults to DEFAULT and succeeds")
void blankModeDefaults() throws Exception {
wireDocuments(2);
MergeMultiplePagesRequest req = base();
req.setMode(" ");
req.setPagesPerSheet(2);
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@@ -1,260 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageFitDestination;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
/**
* Additional branch coverage for {@link SplitPdfByChaptersController}: nested-bookmark depth
* limiting, the same-page bookmark merge path, and the AcroForm-bearing branch that routes per
* chapter through PDFBox instead of JPDFium. All PDFs are built in memory.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SplitPdfByChaptersController additional branch tests")
class SplitPdfByChaptersControllerMoreTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private TempFileManager tempFileManager;
@InjectMocks private SplitPdfByChaptersController controller;
@BeforeEach
void setUp() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
inv ->
Files.createTempFile(tempDir, "ch", inv.<String>getArgument(0))
.toFile());
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private PDOutlineItem item(PDDocument doc, String title, int pageIndex) {
PDOutlineItem oi = new PDOutlineItem();
oi.setTitle(title);
PDPageFitDestination dest = new PDPageFitDestination();
dest.setPage(doc.getPage(pageIndex));
oi.setDestination(dest);
return oi;
}
private MockMultipartFile asFile(byte[] bytes) {
return new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
}
private SplitPdfByChaptersRequest request(byte[] bytes, int level, boolean dupes) {
SplitPdfByChaptersRequest req = new SplitPdfByChaptersRequest();
req.setFileInput(asFile(bytes));
req.setBookmarkLevel(level);
req.setIncludeMetadata(false);
req.setAllowDuplicates(dupes);
return req;
}
private List<byte[]> unzip(Resource zip) throws IOException {
List<byte[]> out = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zip.getContentAsByteArray()))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
out.add(zis.readAllBytes());
zis.closeEntry();
}
}
return out;
}
private int totalPages(List<byte[]> entries) throws IOException {
int total = 0;
for (byte[] data : entries) {
try (PDDocument doc = Loader.loadPDF(data)) {
total += doc.getNumberOfPages();
}
}
return total;
}
@Nested
@DisplayName("Nested bookmarks")
class NestedBookmarks {
/** Builds a doc with top-level chapters, each carrying one child bookmark. */
private byte[] nestedDoc() throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 8; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
PDOutlineItem chapter1 = item(doc, "Chapter 1", 0);
chapter1.addLast(item(doc, "Section 1.1", 2));
outline.addLast(chapter1);
PDOutlineItem chapter2 = item(doc, "Chapter 2", 4);
chapter2.addLast(item(doc, "Section 2.1", 6));
outline.addLast(chapter2);
Path p = tempDir.resolve("nested.pdf");
doc.save(p.toFile());
return Files.readAllBytes(p);
}
}
@Test
@DisplayName("level 0 collects only top-level chapters")
void levelZeroTopLevelOnly() throws Exception {
ResponseEntity<Resource> response = controller.splitPdf(request(nestedDoc(), 0, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
// Only the 2 top-level chapters become split points at level 0.
assertThat(outputs).hasSize(2);
assertThat(totalPages(outputs)).isEqualTo(8);
}
@Test
@DisplayName("a deeper level descends into child bookmarks")
void deeperLevelIncludesChildren() throws Exception {
int topLevelCount =
unzip(controller.splitPdf(request(nestedDoc(), 0, true)).getBody()).size();
ResponseEntity<Resource> response = controller.splitPdf(request(nestedDoc(), 2, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
// Descending into children yields at least as many split points as the top level.
assertThat(outputs.size()).isGreaterThanOrEqualTo(topLevelCount);
assertThat(totalPages(outputs)).isEqualTo(8);
}
}
@Nested
@DisplayName("Same-page bookmark merge")
class SamePageMerge {
@Test
@DisplayName("bookmarks on the same page are merged when duplicates are disallowed")
void mergesSamePageBookmarks() throws Exception {
byte[] bytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 4; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
// Two bookmarks both pointing at page index 0 -> same start/end -> merged.
outline.addLast(item(doc, "Intro A", 0));
outline.addLast(item(doc, "Intro B", 0));
outline.addLast(item(doc, "Body", 2));
Path p = tempDir.resolve("samepage.pdf");
doc.save(p.toFile());
bytes = Files.readAllBytes(p);
}
ResponseEntity<Resource> response = controller.splitPdf(request(bytes, 0, false));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
// The two same-page intros collapse, leaving fewer outputs than bookmarks.
assertThat(outputs.size()).isLessThan(3);
assertThat(totalPages(outputs)).isGreaterThan(0);
}
}
@Nested
@DisplayName("Form-bearing PDFs route through PDFBox")
class FormBearing {
private byte[] formDocWithBookmarks() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
for (int i = 0; i < 4; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDTextField field = new PDTextField(acroForm);
field.setPartialName("text_p" + (i + 1));
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
widget.setPage(page);
field.setWidgets(List.of(widget));
page.getAnnotations().add(widget);
acroForm.getFields().add(field);
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
outline.addLast(item(doc, "Chapter 1", 0));
outline.addLast(item(doc, "Chapter 2", 2));
Path p = tempDir.resolve("form.pdf");
doc.save(p.toFile());
return Files.readAllBytes(p);
}
}
@Test
@DisplayName("a PDF with an AcroForm still splits into the expected chapters")
void formPdfSplits() throws Exception {
ResponseEntity<Resource> response =
controller.splitPdf(request(formDocWithBookmarks(), 0, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(totalPages(outputs)).isEqualTo(4);
}
}
}
@@ -1,206 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.ImageType;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Extra branch coverage for {@link ConvertImgPDFController#convertToImage} not exercised by the
* existing tests: the null-result logging branch, the octet-stream media-type fallback, and the
* webp-with-Python path that produces no output files. The Python/ProcessExecutor boundary is
* mocked so no interpreter or external binary ever runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertImgPDFController extra convertToImage branches")
class ConvertImgPDFControllerExtraTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertImgPDFController controller;
private static byte[] tinyPdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
private static PDDocument tinyDocument(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
private static MockMultipartFile pdfFile(byte[] bytes) {
return new MockMultipartFile("fileInput", "source.pdf", "application/pdf", bytes);
}
private ConvertToImageRequest baseRequest(byte[] pdf, String format) {
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(pdfFile(pdf));
request.setImageFormat(format);
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers("all");
request.setIncludeAnnotations(false);
return request;
}
@Nested
@DisplayName("non-webp branches")
class NonWebp {
@Test
@DisplayName("null render result still produces a single-image response")
void nullResultStillResponds() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
ConvertToImageRequest request = baseRequest(pdfBytes, "png");
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
@SuppressWarnings("unchecked")
ResponseEntity<byte[]> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
// Null bytes hit the "resultant bytes is null" log branch but still respond.
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.RGB),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(null);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
any(), any(String.class), any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertThat(response).isSameAs(expected);
}
}
}
@Nested
@DisplayName("webp-with-Python branch")
class WebpWithPython {
@Test
@DisplayName("throws when the Python conversion yields no webp files")
void noWebpFilesProducedThrows() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
ConvertToImageRequest request = baseRequest(pdfBytes, "webp");
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
// ProcessExecutor instance + result are mocked; the empty output dir drives the
// "No WebP files were created" IOException without invoking Python.
ProcessExecutorResult procResult = Mockito.mock(ProcessExecutorResult.class);
Mockito.when(procResult.getMessages()).thenReturn("no output");
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
Mockito.when(executor.runCommandWithOutputHandling(anyList())).thenReturn(procResult);
// parsePageList/generateFilename are stubbed so rearrangePdfPages runs without
// touching the real installation path for script extraction.
java.util.List<Integer> pageOrder = java.util.List.of(0);
Path scriptPath = Path.of("png_to_webp.py");
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<CheckProgramInstall> cpi =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("png"),
any(ImageType.class),
anyBoolean(),
anyInt(),
any(String.class),
anyBoolean()))
.thenReturn("png-image".getBytes());
gu.when(
() ->
GeneralUtils.parsePageList(
any(String[].class), anyInt(), anyBoolean()))
.thenReturn(pageOrder);
gu.when(() -> GeneralUtils.generateFilename(any(), any(String.class)))
.thenReturn("out");
gu.when(() -> GeneralUtils.extractScript("png_to_webp.py")).thenReturn(scriptPath);
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
cpi.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(executor);
// Output directory is empty after the (mocked) run, so the controller throws.
assertThatThrownBy(() -> controller.convertToImage(request))
.isInstanceOf(IOException.class)
.hasMessageContaining("No WebP files were created");
}
}
}
}
@@ -1,253 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.ImageType;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Additional branch coverage for {@link ConvertImgPDFController}: the getMediaType fallback, the
* convertToPdf null/blank colorType and fitOption defaults, multi-image input, and the explicit
* page-selection path of convertToImage. The PdfUtils boundary is mocked so no real rendering or
* external binary runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertImgPDFController additional branch tests")
class ConvertImgPDFControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertImgPDFController controller;
private static byte[] tinyPdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
private static PDDocument tinyDoc(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
@Nested
@DisplayName("getMediaType")
class GetMediaType {
private String invoke(String format) throws Exception {
Method m =
ConvertImgPDFController.class.getDeclaredMethod("getMediaType", String.class);
m.setAccessible(true);
return (String) m.invoke(controller, format);
}
@Test
@DisplayName("known image extension resolves to a concrete mime type")
void knownExtension() throws Exception {
assertThat(invoke("png")).isEqualTo("image/png");
}
@Test
@DisplayName("unknown extension does not resolve to a concrete image mime type")
void unknownExtensionNotConcrete() throws Exception {
// guessContentTypeFromName yields null/octet-stream for an unrecognised extension;
// either way it must not masquerade as a real image type.
String result = invoke("zzz");
assertThat(result).isNotEqualTo("image/png");
}
}
@Nested
@DisplayName("convertToPdf defaults")
class ConvertToPdfDefaults {
@Test
@DisplayName("blank colorType and empty fitOption fall back to color/fillPage")
void blankDefaults() throws Exception {
MockMultipartFile img =
new MockMultipartFile("fileInput", "p.jpg", "image/jpeg", "x".getBytes());
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {img});
request.setColorType(" ");
request.setFitOption("");
request.setAutoRotate(null);
byte[] pdfBytes = "pdf".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
gu.when(() -> GeneralUtils.generateFilename("p.jpg", "_converted.pdf"))
.thenReturn("p_converted.pdf");
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "p_converted.pdf"))
.thenReturn(expected);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertThat(response).isSameAs(expected);
// Blank colorType -> "color"; empty fitOption -> "fillPage".
pu.verify(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)));
}
}
@Test
@DisplayName("multiple images use the first filename for the output")
void multipleImagesUseFirstName() throws Exception {
MockMultipartFile a =
new MockMultipartFile("fileInput", "first.png", "image/png", "a".getBytes());
MockMultipartFile b =
new MockMultipartFile("fileInput", "second.png", "image/png", "b".getBytes());
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {a, b});
request.setColorType("color");
request.setFitOption("fillPage");
request.setAutoRotate(false);
byte[] pdfBytes = "pdf".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
gu.when(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf"))
.thenReturn("first_converted.pdf");
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "first_converted.pdf"))
.thenReturn(expected);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertThat(response).isSameAs(expected);
gu.verify(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf"));
}
}
}
@Nested
@DisplayName("convertToImage explicit page selection")
class ExplicitPages {
@Test
@DisplayName("a specific page-number list is parsed and rendered")
void specificPageList() throws Exception {
byte[] pdfBytes = tinyPdfBytes(3);
MockMultipartFile file =
new MockMultipartFile("fileInput", "src.pdf", "application/pdf", pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("png");
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers("1,3");
request.setIncludeAnnotations(false);
// rearrangePdfPages loads a real document and selects pages 1 and 3.
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDoc(3));
byte[] imageBytes = "img".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.RGB),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(imageBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(imageBytes),
any(String.class),
any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertThat(response).isSameAs(expected);
}
}
}
}
@@ -1,274 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
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.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Additional coverage for {@link ConvertMarkdownToPdf}. Real commonmark parsing is exercised; the
* external WeasyPrint boundary ({@link FileToPdf#convertHtmlToPdf}) and the response writer are
* mocked so no external tool is launched. The ZIP branch uses a real in-memory ZIP archive.
*/
@ExtendWith(MockitoExtension.class)
class ConvertMarkdownToPdfMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@InjectMocks private ConvertMarkdownToPdf controller;
@BeforeEach
void setUp() throws Exception {
lenient().when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
lenient()
.when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(any(byte[].class)))
.thenAnswer(inv -> inv.getArgument(0));
// A real managed temp file so Files.write succeeds before the (mocked) response build.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("md-test", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
// A real temp directory backing the ZIP-extraction branch.
lenient()
.when(tempFileManager.createTempDirectory())
.thenAnswer(inv -> Files.createTempDirectory("md-zip-test"));
}
private static ResponseEntity<Resource> cannedResponse() {
return ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
}
private GeneralFile generalFileOf(String name, String contentType, byte[] bytes) {
GeneralFile gf = new GeneralFile();
gf.setFileInput(new MockMultipartFile("fileInput", name, contentType, bytes));
return gf;
}
@Nested
@DisplayName("plain markdown branch")
class PlainMarkdown {
@Test
@DisplayName("converts a markdown file with a GFM table to PDF")
void markdownWithTable() throws Exception {
String md = "# Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nSome **bold** body text.\n";
GeneralFile gf = generalFileOf("doc.md", "text/markdown", md.getBytes());
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
ArgumentCaptor<byte[]> htmlBytes = ArgumentCaptor.forClass(byte[].class);
ftp.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
isNull(),
htmlBytes.capture(),
eq("converted.html"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn("pdf".getBytes());
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(cannedResponse());
ResponseEntity<Resource> response = controller.markdownToPdf(gf);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String html = new String(htmlBytes.getValue(), StandardCharsets.UTF_8);
// commonmark + tables extension produced a styled table and a heading.
assertThat(html).contains("table table-striped");
assertThat(html).contains("<h1>");
}
}
@Test
@DisplayName("propagates a WeasyPrint conversion failure")
void weasyPrintFailurePropagates() throws Exception {
GeneralFile gf = generalFileOf("doc.md", "text/markdown", "# Hi".getBytes());
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class)) {
ftp.when(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
isNull(),
any(byte[].class),
anyString(),
any(TempFileManager.class),
any(CustomHtmlSanitizer.class)))
.thenThrow(new java.io.IOException("weasyprint failed"));
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(java.io.IOException.class);
}
}
}
@Nested
@DisplayName("zip markdown branch")
class ZipMarkdown {
private byte[] zipWith(Map<String, byte[]> entries) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
zos.putNextEntry(new ZipEntry(e.getKey()));
zos.write(e.getValue());
zos.closeEntry();
}
}
return baos.toByteArray();
}
@Test
@DisplayName("extracts markdown plus an image and converts via the zip path")
void zipWithImageConverts() throws Exception {
Map<String, byte[]> entries = new HashMap<>();
entries.put("index.md", "# Zip Doc\n\n![img](pic.png)\n".getBytes());
entries.put("pic.png", new byte[] {(byte) 0x89, 'P', 'N', 'G'});
byte[] zip = zipWith(entries);
GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip);
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
ftp.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
isNull(),
any(byte[].class),
eq("package.zip"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn("pdf".getBytes());
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(cannedResponse());
ResponseEntity<Resource> response = controller.markdownToPdf(gf);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ftp.verify(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
isNull(),
any(byte[].class),
eq("package.zip"),
any(TempFileManager.class),
any(CustomHtmlSanitizer.class)));
}
}
@Test
@DisplayName("throws when the zip contains no markdown file")
void zipWithoutMarkdownThrows() throws Exception {
Map<String, byte[]> entries = new HashMap<>();
entries.put("readme.txt", "not markdown".getBytes());
byte[] zip = zipWith(entries);
GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip);
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("input validation")
class Validation {
@Test
@DisplayName("throws when fileInput is null")
void nullFileInput() {
GeneralFile gf = new GeneralFile();
gf.setFileInput(null);
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws for a non-markdown, non-zip extension")
void wrongExtension() {
GeneralFile gf = generalFileOf("notes.txt", "text/plain", "hello".getBytes());
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Test
@DisplayName("TableAttributeProvider adds the table class only to table blocks")
void tableAttributeProviderBehaviour() {
Parser parser = Parser.builder().build();
Node paragraph = parser.parse("plain paragraph");
TableAttributeProvider provider = new TableAttributeProvider();
Map<String, String> attrs = new HashMap<>();
// A non-table node leaves the attribute map untouched.
provider.setAttributes(paragraph.getFirstChild(), "p", attrs);
assertThat(attrs).doesNotContainKey("class");
}
}
@@ -1,458 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
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.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.OfficeDocumentSanitizer;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.ProcessExecutor.Processes;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Unit tests for {@link ConvertOfficeController}. The external LibreOffice/unoconvert boundary is
* mocked via mockStatic(ProcessExecutor) so no real process is spawned.
*/
@DisplayName("ConvertOfficeController tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertOfficeControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@Mock private OfficeDocumentSanitizer officeDocumentSanitizer;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private TempFileManager tempFileManager;
private ConvertOfficeController controller;
private ConvertOfficeController newController() {
return new ConvertOfficeController(
pdfDocumentFactory,
runtimePathConfig,
customHtmlSanitizer,
officeDocumentSanitizer,
endpointConfiguration,
tempFileManager);
}
@BeforeEach
void setUp() {
controller = newController();
lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice");
lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("unoconvert");
}
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
// ---- reflection helper for the private convertToPdf-supporting methods --------------------
@SuppressWarnings("unchecked")
private <T> T invokeInstance(String methodName, Object... args) throws Exception {
for (Method method : ConvertOfficeController.class.getDeclaredMethods()) {
if (method.getName().equals(methodName) && method.getParameterCount() == args.length) {
method.setAccessible(true);
try {
return (T) method.invoke(controller, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception ex) {
throw ex;
}
throw new RuntimeException(cause);
}
}
}
throw new IllegalStateException(
"No method " + methodName + " with " + args.length + " args");
}
private MockMultipartFile docxFile(byte[] content) {
return new MockMultipartFile(
"fileInput",
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
content);
}
/**
* Configures the static ProcessExecutor so the LibreOffice/uno call returns rc and writes a pdf
* to the outdir if requested.
*/
private ProcessExecutorResult mockExecutor(MockedStatic<ProcessExecutor> pe, int rc) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.LIBRE_OFFICE)).thenReturn(executor);
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(rc);
return result;
}
@Nested
@DisplayName("isValidFileExtension")
class ExtensionValidation {
@Test
@DisplayName("accepts 2-4 char alphanumeric extensions")
void acceptsValid() throws Exception {
assertThat((boolean) invokeInstance("isValidFileExtension", "docx")).isTrue();
assertThat((boolean) invokeInstance("isValidFileExtension", "odt")).isTrue();
assertThat((boolean) invokeInstance("isValidFileExtension", "xls")).isTrue();
}
@Test
@DisplayName("rejects too-long or symbol extensions")
void rejectsInvalid() throws Exception {
assertThat((boolean) invokeInstance("isValidFileExtension", "toolongext")).isFalse();
assertThat((boolean) invokeInstance("isValidFileExtension", "a")).isFalse();
assertThat((boolean) invokeInstance("isValidFileExtension", "d.x")).isFalse();
}
}
@Nested
@DisplayName("convertToPdf input validation")
class InputValidation {
@Test
@DisplayName("blank filename throws file-no-name exception")
void blankFilename() {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "", "application/octet-stream", "x".getBytes());
assertThatThrownBy(() -> controller.convertToPdf(file))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("unsupported/invalid extension throws invalid-extension exception")
void invalidExtension() {
MockMultipartFile file =
new MockMultipartFile(
"fileInput",
"archive.toolong",
"application/octet-stream",
"x".getBytes());
assertThatThrownBy(() -> controller.convertToPdf(file))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("convertToPdf conversion paths")
class ConversionPaths {
@Test
@DisplayName("uses unoconvert when available and returns produced pdf")
void unoconvertSuccess() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(true);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
ArgumentCaptor<List<String>> cmd = ArgumentCaptor.forClass(List.class);
when(executor.runCommandWithOutputHandling(cmd.capture()))
.thenAnswer(
inv -> {
// unoconvert writes directly to the output path (last arg)
List<String> command = inv.getArgument(0);
Path out = Path.of(command.get(command.size() - 1));
Files.writeString(out, "%PDF-1.4 produced");
return result;
});
File pdf = controller.convertToPdf(docxFile("real-docx".getBytes()));
assertThat(pdf).exists();
assertThat(Files.size(pdf.toPath())).isGreaterThan(0L);
assertThat(cmd.getValue().get(0)).isEqualTo("unoconvert");
// sanitizer must have been consulted for the docx
Mockito.verify(officeDocumentSanitizer).sanitize(any(byte[].class), anyString());
deleteWorkdir(pdf);
}
}
@Test
@DisplayName("falls back to soffice when unoconvert is unavailable")
void sofficeFallbackWhenUnoUnavailable() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
ArgumentCaptor<List<String>> cmd = ArgumentCaptor.forClass(List.class);
when(executor.runCommandWithOutputHandling(cmd.capture()))
.thenAnswer(
inv -> {
// soffice writes <basename>.pdf into the --outdir (workDir)
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("report.pdf");
Files.writeString(out, "%PDF soffice");
return result;
});
File pdf = controller.convertToPdf(docxFile("real-docx".getBytes()));
assertThat(pdf).exists();
assertThat(cmd.getValue().get(0)).isEqualTo("soffice");
assertThat(cmd.getValue()).contains("--headless", "--convert-to", "pdf");
deleteWorkdir(pdf);
}
}
@Test
@DisplayName("non-zero exit code throws IllegalStateException")
void nonZeroExit() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 3);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("exit 3");
}
}
@Test
@DisplayName("no produced pdf (rc 0 but no file) throws IllegalStateException")
void noProducedPdf() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
// rc 0 but nothing written to workDir -> "No PDF produced."
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
.isInstanceOf(IllegalStateException.class);
}
}
@Test
@DisplayName("empty produced pdf throws IllegalStateException")
void emptyProducedPdf() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("report.pdf");
Files.write(out, new byte[0]);
return result;
});
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("empty");
}
}
@Test
@DisplayName("html input is routed through the html sanitizer")
void htmlSanitized() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(customHtmlSanitizer.sanitize(anyString())).thenReturn("<html>clean</html>");
MockMultipartFile html =
new MockMultipartFile(
"fileInput",
"page.html",
"text/html",
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("page.pdf");
Files.writeString(out, "%PDF html");
return result;
});
File pdf = controller.convertToPdf(html);
assertThat(pdf).exists();
Mockito.verify(customHtmlSanitizer).sanitize(anyString());
Mockito.verifyNoInteractions(officeDocumentSanitizer);
deleteWorkdir(pdf);
}
}
}
@Nested
@DisplayName("processFileToPDF endpoint")
class EndpointTests {
@Test
@DisplayName("happy path loads, saves and cleans up the work directory")
void happyPath() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
File tempOutFile = Files.createTempFile(tempDir, "out", ".pdf").toFile();
TempFile tempOut = mock(TempFile.class);
when(tempOut.getFile()).thenReturn(tempOutFile);
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(tempOut);
PDDocument doc = new PDDocument();
doc.addPage(new PDPage());
when(pdfDocumentFactory.load(any(File.class))).thenReturn(doc);
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(docxFile("docx-bytes".getBytes()));
ResponseEntity<Resource> expected = streamingOk("pdf".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("report.pdf");
Files.writeString(out, "%PDF produced");
return result;
});
gu.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
.thenReturn("report_convertedToPDF.pdf");
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.processFileToPDF(generalFile);
assertThat(response).isSameAs(expected);
wr.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()));
}
doc.close();
}
@Test
@DisplayName("conversion failure propagates and does not return a response")
void conversionFailurePropagates() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(docxFile("docx".getBytes()));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 1);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
assertThatThrownBy(() -> controller.processFileToPDF(generalFile))
.isInstanceOf(IllegalStateException.class);
// a failed conversion never reaches the document factory
Mockito.verifyNoInteractions(pdfDocumentFactory);
}
}
}
private static void deleteWorkdir(File producedPdf) throws IOException {
if (producedPdf != null && producedPdf.getParentFile() != null) {
org.apache.commons.io.FileUtils.deleteDirectory(producedPdf.getParentFile());
}
}
}
@@ -1,215 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Files;
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.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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional coverage for {@link ConvertPDFToExcelController}. Tabula runs in-process, so documents
* are built in-memory: an empty page exercises the no-content branch, a multi-page document drives
* the page loop, and a bordered-grid page drives the workbook-writing path. The managed temp file
* is a real file so the workbook is written to disk.
*/
@ExtendWith(MockitoExtension.class)
class ConvertPDFToExcelControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ConvertPDFToExcelController controller;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("xlsx-test", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private static MockMultipartFile pdf(String name) {
return new MockMultipartFile("fileInput", name, "application/pdf", "pdf".getBytes());
}
private static PDDocument blankPages(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
/** Build a single-page document with a drawn bordered grid that Tabula can detect. */
private static PDDocument borderedTableDoc() throws Exception {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
float left = 60f;
float top = 700f;
float colW = 120f;
float rowH = 30f;
int cols = 3;
int rows = 3;
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setLineWidth(1f);
// Horizontal rules.
for (int r = 0; r <= rows; r++) {
float y = top - r * rowH;
cs.moveTo(left, y);
cs.lineTo(left + cols * colW, y);
cs.stroke();
}
// Vertical rules.
for (int c = 0; c <= cols; c++) {
float x = left + c * colW;
cs.moveTo(x, top);
cs.lineTo(x, top - rows * rowH);
cs.stroke();
}
// Cell text.
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
cs.beginText();
cs.setFont(font, 10);
cs.newLineAtOffset(left + c * colW + 5, top - r * rowH - 20);
cs.showText("R" + r + "C" + c);
cs.endText();
}
}
}
return doc;
}
@Nested
@DisplayName("no-content branches")
class NoContent {
@Test
@DisplayName("single blank page yields no content")
void blankPageNoContent() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("data.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(blankPages(1));
ResponseEntity<Resource> response = controller.pdfToExcel(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("multi-page blank document iterates all pages then yields no content")
void multiBlankPagesNoContent() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("multi.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(blankPages(3));
ResponseEntity<Resource> response = controller.pdfToExcel(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@Nested
@DisplayName("workbook-writing branch")
class WorkbookWriting {
@Test
@DisplayName(
"a bordered table page produces an xlsx response or, if undetected, no content")
void borderedTableProducesXlsx() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("table.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(borderedTableDoc());
ResponseEntity<Resource> response = controller.pdfToExcel(request);
// Lattice detection depends on the Tabula build; accept either outcome but assert the
// success path produced a real, non-empty xlsx body.
if (response.getStatusCode() == HttpStatus.OK) {
assertThat(response.getHeaders().getContentType().toString())
.contains("spreadsheetml.sheet");
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("table.xlsx");
assertThat(response.getBody()).isNotNull();
} else {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
}
@Nested
@DisplayName("error propagation")
class Errors {
@Test
@DisplayName("propagates a document load failure and closes the temp file")
void loadFailurePropagates() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("corrupt.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom"));
assertThatThrownBy(() -> controller.pdfToExcel(request))
.isInstanceOf(java.io.IOException.class);
}
}
@Test
@DisplayName("sanity: blank pages produce a parseable empty document")
void blankDocumentSanity() throws Exception {
try (PDDocument doc = blankPages(1);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.save(out);
assertThat(out.size()).isPositive();
}
}
}
@@ -1,329 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
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.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional tests for {@link ConvertPDFToOffice}. The office-format conversions delegate to {@code
* PDFToFile.processPdfToOfficeFormat}, which shells out to LibreOffice through the static {@link
* ProcessExecutor} factory. Here that factory is mocked with {@code mockStatic}; the mocked
* command-runner writes the expected output file into the LibreOffice {@code --outdir} so the real
* {@code PDFToFile} flow completes and a file-backed response is produced. No real LibreOffice
* runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertPDFToOfficeMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private RuntimePathConfig runtimePathConfig;
@InjectMocks private ConvertPDFToOffice controller;
@BeforeEach
void setUp() throws Exception {
// Real temp files backing TempFileManager so the file-backed response can be read back.
lenient()
.when(tempFileManager.createManagedTempFile(any()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("conv-out", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
// PDFToFile creates its own TempFile(manager, suffix) which calls manager.createTempFile.
lenient()
.when(tempFileManager.createTempFile(any()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("conv-in", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
return f;
});
// PDFToFile also creates a TempDirectory for LibreOffice output.
lenient()
.when(tempFileManager.createTempDirectory())
.thenAnswer(inv -> Files.createTempDirectory("conv-dir"));
// Force the soffice fallback path (uno disabled) and a deterministic soffice binary name.
lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("");
lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice");
}
private MockMultipartFile pdfFile() {
return new MockMultipartFile(
"fileInput",
"document.pdf",
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4".getBytes());
}
private MockMultipartFile nonPdfFile() {
return new MockMultipartFile(
"fileInput", "document.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
}
private static byte[] readResource(Resource resource) throws IOException {
try (InputStream in = resource.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
in.transferTo(baos);
return baos.toByteArray();
}
}
/**
* Stubs the LibreOffice executor so that running the soffice command writes a fake output file
* (named {@code document.<ext>}) into the directory that follows {@code --outdir}.
*/
private void stubLibreOfficeWritesOutput(
MockedStatic<ProcessExecutor> mockedFactory, String primaryExt) throws Exception {
ProcessExecutor executor = mock(ProcessExecutor.class);
ProcessExecutorResult okResult = mock(ProcessExecutorResult.class);
lenient().when(okResult.getRc()).thenReturn(0);
when(executor.runCommandWithOutputHandling(any()))
.thenAnswer(
inv -> {
List<String> cmd = inv.getArgument(0);
int outDirIdx = cmd.indexOf("--outdir");
Path outDir = Path.of(cmd.get(outDirIdx + 1));
Path outFile = outDir.resolve("document." + primaryExt);
Files.write(
outFile, "converted-bytes".getBytes(StandardCharsets.UTF_8));
return okResult;
});
mockedFactory
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
.thenReturn(executor);
}
@Nested
@DisplayName("Presentation conversion")
class PresentationConversion {
@Test
@DisplayName("pptx output streams the converted file back with 200")
void presentationPptxSuccess() throws Exception {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pptx");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "pptx");
ResponseEntity<Resource> response = controller.processPdfToPresentation(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("non-PDF input returns 400 without invoking LibreOffice")
void presentationNonPdfReturnsBadRequest() throws Exception {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(nonPdfFile());
request.setOutputFormat("pptx");
ResponseEntity<Resource> response = controller.processPdfToPresentation(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
@Test
@DisplayName("LibreOffice IOException propagates from presentation conversion")
void presentationLibreOfficeFailurePropagates() throws Exception {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pptx");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = mock(ProcessExecutor.class);
when(executor.runCommandWithOutputHandling(any()))
.thenThrow(new IOException("soffice crashed"));
mockedFactory
.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.LIBRE_OFFICE))
.thenReturn(executor);
assertThrows(IOException.class, () -> controller.processPdfToPresentation(request));
}
}
}
@Nested
@DisplayName("Word conversion")
class WordConversion {
@Test
@DisplayName("docx output streams the converted file back with 200")
void wordDocxSuccess() throws Exception {
PdfToWordRequest request = new PdfToWordRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("docx");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "docx");
ResponseEntity<Resource> response = controller.processPdfToWord(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("unsupported output format returns 400")
void wordUnsupportedFormatReturnsBadRequest() throws Exception {
PdfToWordRequest request = new PdfToWordRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("bogus");
ResponseEntity<Resource> response = controller.processPdfToWord(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
@Nested
@DisplayName("Text / RTF conversion")
class TextRtfConversion {
@Test
@DisplayName("txt output uses PDFBox stripper, not LibreOffice")
void txtUsesStripper() throws Exception {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("txt");
PDDocument realDoc = new PDDocument();
realDoc.addPage(new PDPage());
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(realDoc);
ResponseEntity<Resource> response = controller.processPdfToRTForTXT(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.TEXT_PLAIN, response.getHeaders().getContentType());
}
@Test
@DisplayName("rtf output delegates to LibreOffice and streams back")
void rtfDelegatesToLibreOffice() throws Exception {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("rtf");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "rtf");
ResponseEntity<Resource> response = controller.processPdfToRTForTXT(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("txt branch closes temp file and rethrows when stripper load fails")
void txtLoadFailurePropagates() throws Exception {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("txt");
when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenThrow(new IOException("cannot parse pdf"));
IOException thrown =
assertThrows(IOException.class, () -> controller.processPdfToRTForTXT(request));
assertEquals("cannot parse pdf", thrown.getMessage());
}
}
@Nested
@DisplayName("XML conversion")
class XmlConversion {
@Test
@DisplayName("xml output streams the converted file back with 200")
void xmlSuccess() throws Exception {
PDFFile file = new PDFFile();
file.setFileInput(pdfFile());
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "xml");
ResponseEntity<Resource> response = controller.processPdfToXML(file);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("non-PDF input returns 400 for xml conversion")
void xmlNonPdfReturnsBadRequest() throws Exception {
PDFFile file = new PDFFile();
file.setFileInput(nonPdfFile());
ResponseEntity<Resource> response = controller.processPdfToXML(file);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
}
@@ -1,591 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
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.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
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;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Additional coverage for {@link ConvertPDFToPDFA} focusing on the end-to-end conversion flows
* (handlePdfAConversion / handlePdfXConversion / convertPDDocumentToPDFA) and the Ghostscript
* command builders. The external ghostscript/qpdf boundary is mocked with mockStatic, so no real
* binary is ever executed.
*/
@DisplayName("ConvertPDFToPDFA additional flow tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertPDFToPDFAMoreTest {
@TempDir Path tempDir;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
}
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
// ---- reflection helpers ----------------------------------------------------------------
@SuppressWarnings("unchecked")
private <T> T invokeInstance(Object target, String methodName, Object... args)
throws Exception {
Method method = findMethod(methodName, args.length);
method.setAccessible(true);
try {
return (T) method.invoke(target, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception ex) {
throw ex;
}
throw new RuntimeException(cause);
}
}
private static Method findMethod(String methodName, int argCount) {
for (Method method : ConvertPDFToPDFA.class.getDeclaredMethods()) {
if (method.getName().equals(methodName) && method.getParameterCount() == argCount) {
return method;
}
}
throw new IllegalStateException(
"No method " + methodName + " with " + argCount + " params");
}
private static Object resolvePdfaProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfaProfile")) {
enumClass = inner;
}
}
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
private static Object resolvePdfXProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfXProfile")) {
enumClass = inner;
}
}
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
// ---- pdf builders ----------------------------------------------------------------------
private PDDocument simplePdf() throws IOException {
PDDocument document = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(100, 700);
cs.showText("hello world");
cs.endText();
}
return document;
}
private byte[] simplePdfBytes() throws IOException {
try (PDDocument document = simplePdf()) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
}
private MockMultipartFile pdfFile() throws IOException {
return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", simplePdfBytes());
}
/**
* Sets up a single ProcessExecutor mock returned for every Processes value. The command list
* decides the result: ghostscript conversion commands write a valid output pdf, version/probe
* commands return rc 0, everything else returns rc 0 without side effects.
*/
private ProcessExecutor wireProcessExecutor(MockedStatic<ProcessExecutor> pe, int gsConvertRc)
throws Exception {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
pe.when(
() ->
ProcessExecutor.getInstance(
any(ProcessExecutor.Processes.class), Mockito.anyBoolean()))
.thenReturn(executor);
ProcessExecutorResult okResult = mock(ProcessExecutorResult.class);
lenient().when(okResult.getRc()).thenReturn(0);
ProcessExecutorResult gsResult = mock(ProcessExecutorResult.class);
lenient().when(gsResult.getRc()).thenReturn(gsConvertRc);
lenient().when(gsResult.getMessages()).thenReturn("gs output");
lenient()
.when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
invocation -> {
List<String> command = invocation.getArgument(0);
// The real ghostscript conversion command contains -sOutputFile=...
String outFileArg =
command.stream()
.filter(a -> a.startsWith("-sOutputFile="))
.findFirst()
.orElse(null);
if (outFileArg != null) {
Path out = Path.of(outFileArg.substring("-sOutputFile=".length()));
if (gsConvertRc == 0) {
Files.write(out, simplePdfBytes());
}
return gsResult;
}
// qpdf normalize/clean writes its (last-arg) output file
if (command.contains("--normalize-content=y")) {
// qpdf produced file is the last argument
Path out = Path.of(command.get(command.size() - 1));
Files.write(out, simplePdfBytes());
}
return okResult;
});
return executor;
}
private TempFile managedTempFile() throws IOException {
File f = Files.createTempFile(tempDir, "managed", ".pdf").toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
}
@Nested
@DisplayName("isGhostscriptAvailable")
class GhostscriptAvailability {
@Test
@DisplayName("true when gs --version returns rc 0")
void availableWhenRcZero() throws Exception {
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
wireProcessExecutor(pe, 0);
boolean available = invokeInstance(newController(), "isGhostscriptAvailable");
assertThat(available).isTrue();
}
}
@Test
@DisplayName("false when probe throws")
void unavailableWhenThrows() throws Exception {
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenThrow(new IOException("gs missing"));
boolean available = invokeInstance(newController(), "isGhostscriptAvailable");
assertThat(available).isFalse();
}
}
}
@Nested
@DisplayName("handlePdfAConversion via pdfToPdfA endpoint")
class PdfAConversion {
@Test
@DisplayName("Ghostscript success path returns the produced PDF/A-2b file")
void ghostscriptSuccess() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfa-2b");
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
wr.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class),
org.mockito.ArgumentMatchers.contains("_PDFA-2b.pdf")));
}
}
@Test
@DisplayName("PDF/A-1b exercises the part-1 CIDSet/qpdf branch and succeeds")
void pdfA1Success() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfa-1");
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
}
}
@Test
@DisplayName("strict mode runs VeraPDF and a compliant result still returns the file")
void strictCompliantSucceeds() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
stirling.software.SPDF.model.api.security.PDFVerificationResult ok =
new stirling.software.SPDF.model.api.security.PDFVerificationResult();
ok.setCompliant(true);
ok.setStandard("2b");
ok.setComplianceSummary("PDF/A-2b compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfa-2b");
request.setStrict(true);
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
Mockito.verify(veraPDFService).validatePDF(any());
}
}
}
@Nested
@DisplayName("handlePdfXConversion via pdfToPdfA endpoint")
class PdfXConversion {
@Test
@DisplayName("Ghostscript success path returns the produced PDF/X file")
void pdfXSuccess() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfx");
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
wr.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class),
org.mockito.ArgumentMatchers.contains("_PDFX.pdf")));
}
}
@Test
@DisplayName("PDF/X with Ghostscript unavailable throws the conversion-failed exception")
void pdfXNoGhostscript() throws Exception {
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfx");
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
// gs --version returns non-zero -> not available
wireProcessExecutor(pe, 0);
ProcessExecutor executor =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class);
when(notAvail.getRc()).thenReturn(127);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail);
assertThatThrownBy(() -> newController().pdfToPdfA(request))
.isInstanceOf(RuntimeException.class);
}
}
}
@Nested
@DisplayName("convertPDDocumentToPDFA")
class ConvertDocument {
@Test
@DisplayName("Ghostscript success returns converted bytes")
void documentConversionSuccess() throws Exception {
try (PDDocument document = simplePdf();
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
wireProcessExecutor(pe, 0);
byte[] converted = newController().convertPDDocumentToPDFA(document, "pdfa-2b");
assertThat(converted).isNotNull();
assertThat(converted.length).isGreaterThan(0);
}
}
}
@Nested
@DisplayName("Ghostscript command builders")
class CommandBuilders {
@Test
@DisplayName("buildGhostscriptCommand wires PDF/A part, devices and IO files")
void buildsPdfACommand() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("gs"));
Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes());
Path output = workingDir.resolve("out.pdf");
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
Path defFile = Files.write(workingDir.resolve("def.ps"), new byte[] {1});
Object colorProfiles = newColorProfiles(rgb, gray);
Object profile = resolvePdfaProfile("pdfa-1");
Method m =
ConvertPDFToPDFA.class.getDeclaredMethod(
"buildGhostscriptCommand",
Path.class,
Path.class,
colorProfiles.getClass(),
Path.class,
profile.getClass(),
Path.class);
m.setAccessible(true);
@SuppressWarnings("unchecked")
List<String> command =
(List<String>)
m.invoke(
null,
input,
output,
colorProfiles,
workingDir,
profile,
defFile);
assertThat(command).isNotEmpty();
assertThat(command.get(0)).isEqualTo("gs");
assertThat(command).contains("-dPDFA=1", "-sDEVICE=pdfwrite", "-dEmbedAllFonts=true");
assertThat(command).anyMatch(a -> a.startsWith("-sOutputFile="));
}
@Test
@DisplayName("buildGhostscriptCommandX wires PDF/X version and image tuning")
void buildsPdfXCommand() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("gsx"));
Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes());
Path output = workingDir.resolve("out.pdf");
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
Object colorProfiles = newColorProfiles(rgb, gray);
Object profile = resolvePdfXProfile("pdfx");
Method m =
ConvertPDFToPDFA.class.getDeclaredMethod(
"buildGhostscriptCommandX",
Path.class,
Path.class,
colorProfiles.getClass(),
Path.class,
profile.getClass());
m.setAccessible(true);
@SuppressWarnings("unchecked")
List<String> command =
(List<String>)
m.invoke(null, input, output, colorProfiles, workingDir, profile);
assertThat(command).contains("-dPDFX=2008", "-sDEVICE=pdfwrite");
assertThat(command).anyMatch(a -> a.startsWith("-dColorImageResolution="));
}
@Test
@DisplayName("createPdfaDefFile writes a PDFA_def.ps with the profile title")
void createsPdfaDef() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("def"));
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
Object colorProfiles = newColorProfiles(rgb, gray);
Object profile = resolvePdfaProfile("pdfa-2b");
Method m =
ConvertPDFToPDFA.class.getDeclaredMethod(
"createPdfaDefFile",
Path.class,
colorProfiles.getClass(),
profile.getClass());
m.setAccessible(true);
Path defFile = (Path) m.invoke(null, workingDir, colorProfiles, profile);
assertThat(defFile).exists();
String content = Files.readString(defFile);
assertThat(content).contains("PDF/A-2b");
assertThat(content).contains("OutputIntent");
}
@Test
@DisplayName("prepareColorProfiles copies the sRGB icc and writes a gray profile")
void preparesColorProfiles() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("colors"));
Object colorProfiles =
invokeInstance(newController(), "prepareColorProfiles", workingDir);
assertThat(colorProfiles).isNotNull();
Method rgbAccessor = colorProfiles.getClass().getDeclaredMethod("rgb");
rgbAccessor.setAccessible(true);
Path rgb = (Path) rgbAccessor.invoke(colorProfiles);
assertThat(rgb).exists();
assertThat(Files.size(rgb)).isGreaterThan(0L);
}
private Object newColorProfiles(Path rgb, Path gray) throws Exception {
Class<?> recordClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("ColorProfiles")) {
recordClass = inner;
}
}
var ctor = recordClass.getDeclaredConstructor(Path.class, Path.class);
ctor.setAccessible(true);
return ctor.newInstance(rgb, gray);
}
}
@Nested
@DisplayName("qpdf helpers")
class QpdfHelpers {
@Test
@DisplayName("normalizePdfWithQpdf returns null when qpdf is unavailable")
void normalizeUnavailable() throws Exception {
Path input = Files.write(tempDir.resolve("n.pdf"), simplePdfBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class);
when(notAvail.getRc()).thenReturn(1);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail);
Path result = invokeInstance(newController(), "normalizePdfWithQpdf", input);
assertThat(result).isNull();
}
}
@Test
@DisplayName("cleanCidSetWithQpdf returns null on exception")
void cleanCidSetThrows() throws Exception {
Path input = Files.write(tempDir.resolve("c.pdf"), simplePdfBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenThrow(new IOException("qpdf boom"));
Path result = invokeInstance(newController(), "cleanCidSetWithQpdf", input);
assertThat(result).isNull();
}
}
}
}
@@ -1,254 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFileManager;
/**
* Coverage for the remaining {@link ConvertPdfJsonController} diagnostic helpers and the job-access
* guard on the GET endpoints. The conversion service boundary is mocked so nothing is rendered.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertPdfJsonController remaining branch coverage")
class ConvertPdfJsonControllerExtraTest {
@Mock private PdfJsonConversionService pdfJsonConversionService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
@InjectMocks private ConvertPdfJsonController controller;
@BeforeEach
void setUp() throws Exception {
Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService");
f.setAccessible(true);
f.set(controller, jobOwnershipService);
}
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
Method m = ConvertPdfJsonController.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(controller, args);
}
@Nested
@DisplayName("looksLikeBase64")
class LooksLikeBase64 {
private boolean looksLikeBase64(String value) throws Exception {
return (boolean) invoke("looksLikeBase64", new Class<?>[] {String.class}, value);
}
@Test
@DisplayName("short strings are never treated as base64")
void shortNotBase64() throws Exception {
assertThat(looksLikeBase64("short")).isFalse();
}
@Test
@DisplayName("a long base64-like string is detected")
void longBase64Detected() throws Exception {
String b64 = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5";
assertThat(looksLikeBase64(b64)).isTrue();
}
@Test
@DisplayName("a long natural-language string is not base64")
void longProseNotBase64() throws Exception {
String prose = "this is a long sentence with spaces and punctuation, not base64!!";
assertThat(looksLikeBase64(prose)).isFalse();
}
}
@Nested
@DisplayName("truncateForLog")
class TruncateForLog {
private String truncate(String value) throws Exception {
return (String) invoke("truncateForLog", new Class<?>[] {String.class}, value);
}
@Test
@DisplayName("short values are returned with whitespace normalised")
void shortNormalised() throws Exception {
assertThat(truncate("a\tb\nc")).isEqualTo("a b c");
}
@Test
@DisplayName("long values are truncated with an ellipsis suffix")
void longTruncated() throws Exception {
String value = "x".repeat(100);
String result = truncate(value);
assertThat(result).endsWith("...");
assertThat(result).hasSize(67); // 64 chars + "..."
}
}
@Nested
@DisplayName("debug/repeat-scan flag readers")
class FlagReaders {
private boolean dumpEnabled() throws Exception {
return (boolean) invoke("isPdfJsonDebugDumpEnabled", new Class<?>[] {});
}
private boolean repeatScanEnabled() throws Exception {
return (boolean) invoke("isPdfJsonRepeatScanEnabled", new Class<?>[] {});
}
@Test
@DisplayName("dump flag reflects the system property")
void dumpFlagFromProperty() throws Exception {
String prev = System.getProperty("spdf.pdfjson.dump");
try {
System.clearProperty("spdf.pdfjson.dump");
assertThat(dumpEnabled()).isFalse();
System.setProperty("spdf.pdfjson.dump", "true");
assertThat(dumpEnabled()).isTrue();
} finally {
restore("spdf.pdfjson.dump", prev);
}
}
@Test
@DisplayName("repeat-scan flag reflects the system property")
void repeatScanFromProperty() throws Exception {
String prev = System.getProperty("spdf.pdfjson.repeatScan");
try {
System.clearProperty("spdf.pdfjson.repeatScan");
assertThat(repeatScanEnabled()).isFalse();
System.setProperty("spdf.pdfjson.repeatScan", "true");
assertThat(repeatScanEnabled()).isTrue();
} finally {
restore("spdf.pdfjson.repeatScan", prev);
}
}
private void restore(String key, String prev) {
if (prev == null) {
System.clearProperty(key);
} else {
System.setProperty(key, prev);
}
}
}
@Nested
@DisplayName("logJsonResponse")
class LogJsonResponse {
@Test
@DisplayName("a null path is logged without throwing")
void nullPathHandled() throws Exception {
// exercises the early null-path guard branch
invoke(
"logJsonResponse",
new Class<?>[] {String.class, java.nio.file.Path.class},
"x",
null);
}
@Test
@DisplayName("happy path with no debug flags returns without reading the file")
void noFlagsNoRead() throws Exception {
String dumpPrev = System.getProperty("spdf.pdfjson.dump");
String scanPrev = System.getProperty("spdf.pdfjson.repeatScan");
try {
System.clearProperty("spdf.pdfjson.dump");
System.clearProperty("spdf.pdfjson.repeatScan");
// a non-existent path must not be read because all flags are off
java.nio.file.Path missing = java.nio.file.Path.of("does-not-exist-123.json");
invoke(
"logJsonResponse",
new Class<?>[] {String.class, java.nio.file.Path.class},
"label",
missing);
} finally {
if (dumpPrev != null) System.setProperty("spdf.pdfjson.dump", dumpPrev);
if (scanPrev != null) System.setProperty("spdf.pdfjson.repeatScan", scanPrev);
}
}
}
@Nested
@DisplayName("GET endpoint job-access guard")
class JobAccessGuard {
@Test
@DisplayName("extractSinglePage rejects an unauthorized job before doing work")
void singlePageRejected() {
doThrow(new SecurityException("denied"))
.when(jobOwnershipService)
.validateJobAccess("bad");
assertThrows(SecurityException.class, () -> controller.extractSinglePage("bad", 1));
verifyNoConversion();
}
@Test
@DisplayName("extractPageFonts rejects an unauthorized job before doing work")
void pageFontsRejected() {
doThrow(new SecurityException("denied"))
.when(jobOwnershipService)
.validateJobAccess("bad");
assertThrows(SecurityException.class, () -> controller.extractPageFonts("bad", 2));
verifyNoConversion();
}
@Test
@DisplayName("exportPartialPdf rejects an unauthorized job before doing work")
void exportPartialRejected() {
when(jobOwnershipService.validateJobAccess(anyString()))
.thenThrow(new SecurityException("denied"));
assertThrows(
SecurityException.class,
() ->
controller.exportPartialPdf(
"bad",
new stirling.software.SPDF.model.json.PdfJsonDocument(),
"out.pdf"));
}
private void verifyNoConversion() {
try {
verify(pdfJsonConversionService, never())
.extractSinglePage(
anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any());
verify(pdfJsonConversionService, never())
.extractPageFonts(
anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any());
} catch (Exception e) {
throw new AssertionError(e);
}
}
}
}
@@ -1,606 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
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.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.json.PdfJsonDocument;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertPdfJsonController additional branch coverage")
class ConvertPdfJsonControllerMoreTest {
@Mock private PdfJsonConversionService pdfJsonConversionService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
@InjectMocks private ConvertPdfJsonController controller;
private final java.util.List<TempFile> createdTempFiles = new java.util.ArrayList<>();
@BeforeEach
void setUp() throws Exception {
// @InjectMocks uses the @RequiredArgsConstructor, so the @Autowired field is not
// auto-injected; wire the JobOwnershipService mock in by reflection.
setJobOwnershipService(jobOwnershipService);
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("more-test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
createdTempFiles.add(tf);
return tf;
});
}
private void setJobOwnershipService(JobOwnershipService service) throws Exception {
Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService");
f.setAccessible(true);
f.set(controller, service);
}
@AfterEach
void tearDown() {
for (TempFile tf : createdTempFiles) {
try {
if (tf.getFile() != null) {
tf.getFile().delete();
}
} catch (Exception ignored) {
// best-effort cleanup
}
}
createdTempFiles.clear();
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
// Disable the @Autowired(required=false) JobOwnershipService for no-auth code paths.
private void clearJobOwnershipService() throws Exception {
setJobOwnershipService(null);
}
@Nested
@DisplayName("convertPdfToJson filename handling")
class ConvertPdfToJsonFilename {
@Test
@DisplayName("Null original filename falls back to document.json")
void nullOriginalFilename() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile("fileInput", null, "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
String cd = response.getHeaders().getFirst("Content-Disposition");
assertThat(cd).contains("document.json");
}
@Test
@DisplayName("Blank original filename falls back to document.json")
void blankOriginalFilename() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile("fileInput", " ", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.json");
}
@Test
@DisplayName("Named file strips extension and appends .json")
void namedFileStripsExtension() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "report.final.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("report.final.json");
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
assertThrows(
IllegalStateException.class, () -> controller.convertPdfToJson(request, false));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("convertJsonToPdf filename handling")
class ConvertJsonToPdfFilename {
@Test
@DisplayName("Null original filename falls back to document.pdf")
void nullOriginalFilename() throws Exception {
MockMultipartFile jsonFile =
new MockMultipartFile("fileInput", null, "application/json", "{}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
doAnswer(writeBytes(1, "pdf".getBytes()))
.when(pdfJsonConversionService)
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertJsonToPdf(request);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.pdf");
}
@Test
@DisplayName("Filename already ending in .pdf is preserved")
void filenameAlreadyPdf() throws Exception {
// toSimpleFileName keeps base name; extension pattern strips the trailing .pdf,
// so a base name that itself ends in .pdf exercises the endsWith branch.
MockMultipartFile jsonFile =
new MockMultipartFile(
"fileInput", "weird.pdf.json", "application/json", "{}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
doAnswer(writeBytes(1, "pdf".getBytes()))
.when(pdfJsonConversionService)
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertJsonToPdf(request);
assertThat(response.getHeaders().getFirst("Content-Disposition")).contains("weird.pdf");
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
MockMultipartFile jsonFile =
new MockMultipartFile(
"fileInput", "doc.json", "application/json", "{}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.convertJsonToPdf(request));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("extractPdfMetadata job-key scoping")
class ExtractMetadataScoping {
@Test
@DisplayName("Uses scoped job key when JobOwnershipService present")
void usesScopedKey() throws Exception {
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("user:scoped-id");
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.extractDocumentMetadata(
eq(pdfFile), eq("user:scoped-id"), any(OutputStream.class));
ResponseEntity<Resource> response = controller.extractPdfMetadata(request);
assertEquals("user:scoped-id", response.getHeaders().getFirst("X-Job-Id"));
verify(jobOwnershipService).createScopedJobKey(anyString());
}
@Test
@DisplayName("Uses raw job key when no JobOwnershipService")
void usesRawKeyWhenNoService() throws Exception {
clearJobOwnershipService();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class));
ResponseEntity<Resource> response = controller.extractPdfMetadata(request);
assertNotNull(response.getHeaders().getFirst("X-Job-Id"));
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("k");
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.extractPdfMetadata(request));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("exportPartialPdf")
class ExportPartialPdf {
@Test
@DisplayName("Null document throws")
void nullDocumentThrows() {
assertThrows(
Exception.class, () -> controller.exportPartialPdf("job", null, "out.pdf"));
}
@Test
@DisplayName("Explicit filename param wins over metadata title")
void filenameParamWins() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(PdfJsonMetadata.builder().title("titleName").build());
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response =
controller.exportPartialPdf("job", doc, "custom.pdf");
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("custom.pdf");
}
@Test
@DisplayName("Falls back to metadata title when filename blank")
void fallbackToMetadataTitle() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(PdfJsonMetadata.builder().title("MyTitle").build());
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, " ");
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("MyTitle.pdf");
}
@Test
@DisplayName("Falls back to document when title null/blank and no filename")
void fallbackToDocumentName() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(PdfJsonMetadata.builder().title(" ").build());
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, null);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.pdf");
}
@Test
@DisplayName("Null metadata falls back to document name")
void nullMetadataFallback() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(null);
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, null);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.pdf");
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.exportUpdatedPages(
anyString(), any(PdfJsonDocument.class), any(OutputStream.class));
assertThrows(
IllegalStateException.class,
() -> controller.exportPartialPdf("job", doc, "out.pdf"));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("extractSinglePage and extractPageFonts errors")
class GetPageErrors {
@Test
@DisplayName("extractSinglePage service exception closes temp file")
void singlePageException() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.extractSinglePage(anyString(), anyInt(), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.extractSinglePage("job", 1));
verify(createdTempFiles.get(0)).close();
}
@Test
@DisplayName("extractPageFonts service exception closes temp file")
void pageFontsException() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.extractPageFonts(anyString(), anyInt(), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.extractPageFonts("job", 1));
verify(createdTempFiles.get(0)).close();
}
@Test
@DisplayName("Page docName carries page number")
void singlePageDocName() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.extractSinglePage(eq("job"), eq(7), any(OutputStream.class));
ResponseEntity<Resource> response = controller.extractSinglePage("job", 7);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("page_7.json");
}
}
@Nested
@DisplayName("validateJobAccess and clearCache delegation")
class JobAccessDelegation {
@Test
@DisplayName("clearCache validates and delegates when service present")
void clearCacheWithService() {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
ResponseEntity<Void> response = controller.clearCache("job-1");
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(jobOwnershipService).validateJobAccess("job-1");
verify(pdfJsonConversionService).clearCachedDocument("job-1");
}
@Test
@DisplayName("clearCache skips validation when no service")
void clearCacheNoService() throws Exception {
clearJobOwnershipService();
ResponseEntity<Void> response = controller.clearCache("job-2");
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfJsonConversionService).clearCachedDocument("job-2");
verify(jobOwnershipService, never()).validateJobAccess(anyString());
}
@Test
@DisplayName("validateJobAccess propagates SecurityException from service")
void validateThrows() {
doThrow(new SecurityException("denied"))
.when(jobOwnershipService)
.validateJobAccess("bad");
assertThrows(SecurityException.class, () -> controller.clearCache("bad"));
verify(pdfJsonConversionService, never()).clearCachedDocument(anyString());
}
}
@Nested
@DisplayName("logJsonResponse diagnostic paths")
class LogJsonResponseDiagnostics {
@Test
@DisplayName("Debug dump writes a copy to configured dir")
void debugDumpWritesCopy(@org.junit.jupiter.api.io.TempDir Path dumpDir) throws Exception {
String previous = System.getProperty("spdf.pdfjson.dump");
System.setProperty("spdf.pdfjson.dump", "true");
String prevDir = System.getProperty("java.io.tmpdir");
// SPDF_PDFJSON_DUMP_DIR env may be unset; controller falls back to java.io.tmpdir.
System.setProperty("java.io.tmpdir", dumpDir.toString());
try {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{\"k\":\"value-string-here\"}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
try (var stream = Files.list(dumpDir)) {
boolean dumped =
stream.anyMatch(p -> p.getFileName().toString().startsWith("pdfjson_"));
assertThat(dumped).isTrue();
}
} finally {
restoreProp("spdf.pdfjson.dump", previous);
restoreProp("java.io.tmpdir", prevDir);
}
}
@Test
@DisplayName("Repeat scan runs without error on repeated strings")
void repeatScanRuns() throws Exception {
String previous = System.getProperty("spdf.pdfjson.repeatScan");
System.setProperty("spdf.pdfjson.repeatScan", "true");
try {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
// Two repeated >=12 char strings plus a long base64-like one to exercise filters.
String json =
"{\"a\":\"repeated-string-value\",\"b\":\"repeated-string-value\","
+ "\"c\":\"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5\"}";
doAnswer(writeBytes(2, json.getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertThat(drainBody(response)).isNotEmpty();
} finally {
restoreProp("spdf.pdfjson.repeatScan", previous);
}
}
@Test
@DisplayName("Repeat scan handles no repeated strings")
void repeatScanNoRepeats() throws Exception {
String previous = System.getProperty("spdf.pdfjson.repeatScan");
System.setProperty("spdf.pdfjson.repeatScan", "true");
try {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
String json = "{\"only-key-here-unique\":\"only-value-here-unique\"}";
doAnswer(writeBytes(2, json.getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
} finally {
restoreProp("spdf.pdfjson.repeatScan", previous);
}
}
}
private static org.mockito.stubbing.Answer<Object> writeBytes(int argIndex, byte[] data) {
return inv -> {
OutputStream os = inv.getArgument(argIndex, OutputStream.class);
os.write(data);
return null;
};
}
private static void restoreProp(String key, String previous) {
if (previous == null) {
System.clearProperty(key);
} else {
System.setProperty(key, previous);
}
}
}
@@ -1,103 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import stirling.software.SPDF.exception.CacheUnavailableException;
import tools.jackson.databind.ObjectMapper;
/**
* Unit tests for {@link ConvertPdfJsonExceptionHandler}. A real ObjectMapper exercises the happy
* path; a throwing mock drives the serialization-failure fallback branches.
*/
@DisplayName("ConvertPdfJsonExceptionHandler")
class ConvertPdfJsonExceptionHandlerTest {
@Nested
@DisplayName("handleCacheUnavailable - success")
class Success {
@Test
@DisplayName("serializes a 410 GONE JSON body with the error details")
void serializesGoneResponse() {
ConvertPdfJsonExceptionHandler handler =
new ConvertPdfJsonExceptionHandler(new ObjectMapper());
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException("cache is gone"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_JSON);
String json = new String(response.getBody());
assertThat(json).contains("cache_unavailable");
assertThat(json).contains("reupload");
assertThat(json).contains("cache is gone");
}
@Test
@DisplayName("tolerates a null exception message")
void toleratesNullMessage() {
ConvertPdfJsonExceptionHandler handler =
new ConvertPdfJsonExceptionHandler(new ObjectMapper());
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException(null));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getBody()).isNotEmpty();
}
}
@Nested
@DisplayName("handleCacheUnavailable - fallback")
class Fallback {
@Test
@DisplayName("uses the literal JSON fallback when every serialization attempt fails")
void literalFallbackWhenAllSerializationFails() {
ObjectMapper mapper = Mockito.mock(ObjectMapper.class);
// Both the primary and the secondary writeValueAsBytes calls fail.
Mockito.when(mapper.writeValueAsBytes(any())).thenThrow(new RuntimeException("boom"));
ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper);
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException("nope"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_JSON);
// Last-ditch hand-written JSON literal.
String json = new String(response.getBody());
assertThat(json).contains("cache_unavailable");
assertThat(json).contains("Cache unavailable");
}
@Test
@DisplayName("recovers via the second serialization attempt when only the first fails")
void secondAttemptSucceeds() {
ObjectMapper mapper = Mockito.mock(ObjectMapper.class);
byte[] fallbackJson = "{\"error\":\"cache_unavailable\"}".getBytes();
// First call throws, second returns serialized bytes.
Mockito.when(mapper.writeValueAsBytes(any()))
.thenThrow(new RuntimeException("first fails"))
.thenReturn(fallbackJson);
ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper);
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException("retry"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getBody()).isEqualTo(fallbackJson);
}
}
}
@@ -1,214 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.SvgToPdfRequest;
import stirling.software.SPDF.utils.SvgToPdf;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Gap coverage for {@link ConvertSvgToPDF}: the multi-file zip path and the empty-output failure
* branches not covered by ConvertSvgToPDFTest.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertSvgToPDF zip and failure branches")
class ConvertSvgToPDFMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private SvgSanitizer svgSanitizer;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ConvertSvgToPDF controller;
@BeforeEach
void setUp() throws Exception {
// Real backing files so the zip/pdf streams can actually be written.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("svg", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private static MockMultipartFile svg(String name, String content) {
return new MockMultipartFile("fileInput", name, "image/svg+xml", content.getBytes());
}
private static List<String> zipNames(Resource resource) throws Exception {
List<String> names = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
names.add(e.getName());
zis.closeEntry();
}
}
return names;
}
@Test
@DisplayName("multiple SVGs in separate mode are returned as a zip")
void multipleSeparateZipsOutput() throws Exception {
byte[] sanitized1 = "<svg>a</svg>".getBytes();
byte[] sanitized2 = "<svg>b</svg>".getBytes();
byte[] pdf1 = "pdf1".getBytes();
byte[] pdf2 = "pdf2".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(
new MockMultipartFile[] {
svg("a.svg", "<svg>1</svg>"), svg("b.svg", "<svg>2</svg>")
});
request.setCombineIntoSinglePdf(false);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized1);
when(svgSanitizer.sanitize("<svg>2</svg>".getBytes())).thenReturn(sanitized2);
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf1)).thenReturn(pdf1);
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf2)).thenReturn(pdf2);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
svg.when(() -> SvgToPdf.convert(sanitized1)).thenReturn(pdf1);
svg.when(() -> SvgToPdf.convert(sanitized2)).thenReturn(pdf2);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipNames(response.getBody());
assertEquals(2, names.size());
}
}
@Nested
@DisplayName("empty-output failures")
class EmptyOutputs {
@Test
@DisplayName("combined mode returns 500 when the combined PDF is empty")
void combinedEmptyOutput() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(true);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
svg.when(() -> SvgToPdf.combineIntoPdf(any())).thenReturn(new byte[0]);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
@Test
@DisplayName("separate mode returns 500 when no file converts successfully")
void separateAllEmpty() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(false);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
// Empty conversion output -> the file is skipped -> no successful conversions.
svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(new byte[0]);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
@Test
@DisplayName("combined mode returns 500 when conversion throws IOException")
void combinedConversionThrows() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(true);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
svg.when(() -> SvgToPdf.combineIntoPdf(any()))
.thenThrow(new IOException("convert failure"));
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
}
@Test
@DisplayName("a single converted SVG uses the pdf-file response path")
void singleConvertedUsesPdfResponse() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
byte[] pdf = "pdf".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("only.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(false);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf)).thenReturn(pdf);
ResponseEntity<Resource> stub =
ResponseEntity.ok(new org.springframework.core.io.ByteArrayResource(pdf));
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(pdf);
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(TempFile.class), anyString()))
.thenReturn(stub);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@@ -1,203 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
/**
* Branch coverage for the disallowed-scheme detection helpers of {@link ConvertWebsiteToPDF}. These
* are pure string transforms exercised by reflection, so no network call or external WeasyPrint
* invocation occurs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertWebsiteToPDF scheme-detection helpers")
class ConvertWebsiteToPDFExtraTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
private ConvertWebsiteToPDF sut;
@BeforeEach
void setUp() {
sut =
new ConvertWebsiteToPDF(
pdfDocumentFactory,
runtimePathConfig,
new ApplicationProperties(),
tempFileManager);
}
private boolean containsDisallowed(String html) throws Exception {
Method m =
ConvertWebsiteToPDF.class.getDeclaredMethod(
"containsDisallowedUriScheme", String.class);
m.setAccessible(true);
return (boolean) m.invoke(sut, html);
}
private String percentDecode(String content) throws Exception {
Method m = ConvertWebsiteToPDF.class.getDeclaredMethod("percentDecode", String.class);
m.setAccessible(true);
return (String) m.invoke(sut, content);
}
private String decodeEntities(String content) throws Exception {
Method m =
ConvertWebsiteToPDF.class.getDeclaredMethod(
"decodeNumericHtmlEntities", String.class);
m.setAccessible(true);
return (String) m.invoke(sut, content);
}
@Nested
@DisplayName("containsDisallowedUriScheme")
class DisallowedScheme {
@Test
@DisplayName("null and empty content are allowed")
void nullAndEmpty() throws Exception {
assertThat(containsDisallowed(null)).isFalse();
assertThat(containsDisallowed("")).isFalse();
}
@Test
@DisplayName("plain safe html is allowed")
void safeHtml() throws Exception {
assertThat(
containsDisallowed(
"<html><body><a href=\"https://x.com\">ok</a></body></html>"))
.isFalse();
}
@Test
@DisplayName("a literal file:/// scheme is rejected")
void literalFileScheme() throws Exception {
assertThat(containsDisallowed("<a href=\"file:///etc/passwd\">x</a>")).isTrue();
}
@Test
@DisplayName("an uppercase FILE: scheme is rejected after lower-casing")
void uppercaseFileScheme() throws Exception {
assertThat(containsDisallowed("<a href=\"FILE://server/share\">x</a>")).isTrue();
}
@Test
@DisplayName("a percent-encoded file scheme separator is rejected")
void percentEncodedSeparator() throws Exception {
// file:%2f%2f decodes to file:// during normalization
assertThat(containsDisallowed("<a href=\"file:%2f%2fetc/passwd\">x</a>")).isTrue();
}
@Test
@DisplayName("an html-entity encoded slash sequence is rejected")
void htmlEntitySlashes() throws Exception {
// file:&#47;&#47; -> file:// after numeric-entity decoding
assertThat(containsDisallowed("<a href=\"file:&#47;&#47;etc\">x</a>")).isTrue();
}
@Test
@DisplayName("a named-entity colon/slash sequence is rejected")
void namedEntitySlashes() throws Exception {
// file&colon;&sol;&sol; -> file:// after named-entity replacement
assertThat(containsDisallowed("<a href=\"file&colon;&sol;&sol;etc\">x</a>")).isTrue();
}
@Test
@DisplayName("the word 'profile:' is not mistaken for a file scheme")
void wordBoundaryGuard() throws Exception {
// the (?<![a-z0-9_]) lookbehind prevents matching the 'file' inside 'profile'
assertThat(containsDisallowed("<span>profile://something</span>")).isFalse();
}
}
@Nested
@DisplayName("percentDecode")
class PercentDecode {
@Test
@DisplayName("decodes valid percent escapes")
void decodesValid() throws Exception {
assertThat(percentDecode("a%2fb")).isEqualTo("a/b");
}
@Test
@DisplayName("leaves a trailing incomplete escape untouched")
void trailingIncomplete() throws Exception {
// not enough trailing chars for a full %XX -> appended literally
assertThat(percentDecode("end%2")).isEqualTo("end%2");
}
@Test
@DisplayName("leaves a non-hex escape untouched")
void nonHexEscape() throws Exception {
assertThat(percentDecode("a%zzb")).isEqualTo("a%zzb");
}
@Test
@DisplayName("content with no escapes is returned unchanged")
void noEscapes() throws Exception {
assertThat(percentDecode("plain text")).isEqualTo("plain text");
}
}
@Nested
@DisplayName("decodeNumericHtmlEntities")
class DecodeNumericHtmlEntities {
@Test
@DisplayName("decodes a decimal numeric entity")
void decimalEntity() throws Exception {
// &#47; is '/'
assertThat(decodeEntities("a&#47;b")).isEqualTo("a/b");
}
@Test
@DisplayName("decodes a hexadecimal numeric entity")
void hexEntity() throws Exception {
// &#x2f; is '/'
assertThat(decodeEntities("a&#x2f;b")).isEqualTo("a/b");
}
@Test
@DisplayName("content without entities is unchanged")
void noEntities() throws Exception {
assertThat(decodeEntities("nothing here")).isEqualTo("nothing here");
}
}
@Test
@DisplayName("controller is constructed with its collaborators")
void constructed() {
assertThat(sut).isNotNull();
}
@Test
@DisplayName("reflection invocation surfaces are wired correctly")
void reflectionWired() throws Exception {
// guards against a refactor renaming the private helpers used above
try {
assertThat(containsDisallowed("safe")).isFalse();
} catch (InvocationTargetException e) {
throw new AssertionError("helper invocation failed", e.getCause());
}
}
}
@@ -1,214 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
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.eq;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
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 org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.pdf.parser.PdfModels.Bounds;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Additional coverage for {@link ExtractCSVController}. The Tabula parser is mocked so
* deterministic table fragments drive the single-table, multi-table and no-table response branches;
* documents are built in-memory.
*/
@ExtendWith(MockitoExtension.class)
class ExtractCSVControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TabulaTableParser tabulaTableParser;
@InjectMocks private ExtractCSVController controller;
private static PDDocument docWithPages(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage());
}
return doc;
}
private static MockMultipartFile pdf(String name) {
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes());
}
/** Build a TableFragment whose only meaningful payload for CSV output is rawRows. */
private static TableFragment fragment(List<List<String>> rawRows) {
return new TableFragment(
"tbl",
1,
new Bounds(0f, 0f, 100f, 100f),
List.of(),
List.of(),
rawRows,
rawRows.isEmpty() ? 0 : rawRows.get(0).size(),
1.0f,
List.of(),
null);
}
@Nested
@DisplayName("response shape by table count")
class ResponseShape {
@Test
@DisplayName("returns no content when no tables are found")
void noTablesNoContent() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("data.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1))).thenReturn(List.of());
ResponseEntity<?> response = controller.pdfToCsv(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("returns a single CSV body when exactly one table is found")
void singleTableCsv() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("report.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(
List.of(
fragment(
List.of(
List.of("Name", "Age"),
List.of("Alice", "30")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType().toString()).startsWith("text/csv");
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("report_extracted.csv");
assertThat(response.getBody().toString()).contains("Name").contains("Alice");
}
@Test
@DisplayName("returns a zip when multiple tables span multiple pages")
void multiTableZip() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("multi.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(2));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(List.of(fragment(List.of(List.of("a", "b")))));
when(tabulaTableParser.parse(any(PDDocument.class), eq(2)))
.thenReturn(
List.of(
fragment(List.of(List.of("c", "d"))),
fragment(List.of(List.of("e", "f")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("multi_extracted.zip");
byte[] body = (byte[]) response.getBody();
assertThat(zipEntryNames(body))
.containsExactlyInAnyOrder(
"multi_p1_t1.csv", "multi_p2_t1.csv", "multi_p2_t2.csv");
}
private List<String> zipEntryNames(byte[] zipBytes) throws Exception {
java.util.List<String> names = new java.util.ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
names.add(entry.getName());
zis.closeEntry();
}
}
return names;
}
}
@Nested
@DisplayName("error propagation")
class Errors {
@Test
@DisplayName("propagates a parser failure")
void parserFailurePropagates() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("bad.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenThrow(new java.io.IOException("parse boom"));
assertThatThrownBy(() -> controller.pdfToCsv(request))
.isInstanceOf(java.io.IOException.class);
}
@Test
@DisplayName("propagates a document load failure")
void loadFailurePropagates() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("corrupt.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom"));
assertThatThrownBy(() -> controller.pdfToCsv(request))
.isInstanceOf(java.io.IOException.class);
}
}
@Test
@DisplayName("single table CSV body is quote-wrapped per the EXCEL/QuoteMode.ALL format")
void csvBodyIsQuoted() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("q.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(List.of(fragment(List.of(List.of("x", "y")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
String body = response.getBody().toString();
// QuoteMode.ALL wraps every field in double quotes.
assertThat(body).contains("\"x\"").contains("\"y\"");
assertThat(body.getBytes(StandardCharsets.UTF_8)).isNotEmpty();
}
}
@@ -1,261 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
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.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Gap coverage for {@link PdfVectorExportController#convertPdfToVector} and the Ghostscript
* PDF-to-vector helper, complementing PdfVectorExportControllerTest (which only covers the
* PostScript-to-PDF endpoint).
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("PdfVectorExportController convertPdfToVector")
class PdfVectorExportControllerMoreTest {
private final List<Path> tempPaths = new ArrayList<>();
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private ProcessExecutor ghostscriptExecutor;
@InjectMocks private PdfVectorExportController controller;
// Real manager used only to mint genuine TempFile instances for the mock to hand back.
private final TempFileManager realTempFileManager =
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
private Map<ProcessExecutor.Processes, ProcessExecutor> originalExecutors;
@BeforeEach
void setup() throws Exception {
// Return a real TempFile so no Mockito when() runs re-entrantly inside the thenAnswer.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(inv -> realTempFileManager.createManagedTempFile(inv.getArgument(0)));
lenient()
.when(tempFileManager.createTempFile(any()))
.thenAnswer(
invocation -> {
String suffix = invocation.<String>getArgument(0);
Path path =
Files.createTempFile("vec_in", suffix == null ? "" : suffix);
tempPaths.add(path);
return path.toFile();
});
Field instancesField = ProcessExecutor.class.getDeclaredField("instances");
instancesField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<ProcessExecutor.Processes, ProcessExecutor> instances =
(Map<ProcessExecutor.Processes, ProcessExecutor>) instancesField.get(null);
originalExecutors = Map.copyOf(instances);
instances.clear();
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor);
}
@AfterEach
void tearDown() throws Exception {
Field instancesField = ProcessExecutor.class.getDeclaredField("instances");
instancesField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<ProcessExecutor.Processes, ProcessExecutor> instances =
(Map<ProcessExecutor.Processes, ProcessExecutor>) instancesField.get(null);
instances.clear();
if (originalExecutors != null) {
instances.putAll(originalExecutors);
}
reset(ghostscriptExecutor, tempFileManager, endpointConfiguration);
for (Path path : tempPaths) {
Files.deleteIfExists(path);
}
tempPaths.clear();
}
private ProcessExecutorResult okResult() {
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
lenient().when(result.getRc()).thenReturn(0);
lenient().when(result.getMessages()).thenReturn("");
return result;
}
private static PdfVectorExportRequest request(String outputFormat) {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "in.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[] {1});
PdfVectorExportRequest request = new PdfVectorExportRequest();
request.setFileInput(file);
request.setOutputFormat(outputFormat);
return request;
}
@Nested
@DisplayName("media types per output format")
class MediaTypes {
@Test
@DisplayName("eps output yields application/postscript")
void epsContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("eps"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
@Test
@DisplayName("ps output yields application/postscript")
void psContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("ps"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
@Test
@DisplayName("pcl output yields the HP-PCL media type")
void pclContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("pcl"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/vnd.hp-PCL"));
}
@Test
@DisplayName("xps output yields the MS XPS media type")
void xpsContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("xps"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/vnd.ms-xpsdocument"));
}
@Test
@DisplayName("null output format defaults to eps")
void nullOutputFormatDefaultsToEps() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request(null));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
@Test
@DisplayName("uppercase output format is normalized to lowercase")
void uppercaseFormatNormalized() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("EPS"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
}
@Nested
@DisplayName("failure paths")
class Failures {
@Test
@DisplayName("disabled Ghostscript group throws a conversion exception")
void ghostscriptDisabledThrows() {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps")));
}
@Test
@DisplayName("non-zero Ghostscript return code throws a conversion exception")
void nonZeroReturnCodeThrows() {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult bad = mock(ProcessExecutorResult.class);
lenient().when(bad.getRc()).thenReturn(1);
lenient().when(bad.getMessages()).thenReturn("some non-critical failure");
try {
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(bad);
} catch (Exception e) {
throw new RuntimeException(e);
}
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps")));
}
@Test
@DisplayName("an unsupported output format is rejected before any device mapping")
void unsupportedFormatThrows() {
// "svg" is not in the validated set and falls through to the device-switch default.
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("svg")));
}
}
@Test
@DisplayName("Ghostscript runs the configured command for a successful conversion")
void runsGhostscriptCommand() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("eps"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
org.mockito.Mockito.verify(ghostscriptExecutor).runCommandWithOutputHandling(any());
}
}

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