mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3869072cf7 | ||
|
|
3817779b9e | ||
|
|
c4e66f2c2d | ||
|
|
c27fd4db69 | ||
|
|
6c85200eb9 | ||
|
|
467f3a86c4 | ||
|
|
9d3701a585 | ||
|
|
c22ecc6c09 | ||
|
|
b38c849726 | ||
|
|
0ae7052dcb | ||
|
|
41f1cb2c22 | ||
|
|
ff3e3bd0fc | ||
|
|
54042c8e5e | ||
|
|
bb92ecc143 | ||
|
|
7ab30d2629 | ||
|
|
69fc4d5bc1 | ||
|
|
276eb8f2a7 | ||
|
|
e44da5c410 | ||
|
|
0beff1a92b | ||
|
|
2e023a6e78 | ||
|
|
425b76e9a7 | ||
|
|
7e523d48d7 | ||
|
|
b8cb020e59 | ||
|
|
a92722ff13 | ||
|
|
fed7ad300f | ||
|
|
c8af6e3b7e | ||
|
|
82ec2acaba | ||
|
|
5e97746721 | ||
|
|
14245d33d1 | ||
|
|
84739e8b0e | ||
|
|
0996277c41 | ||
|
|
d508bc41bf | ||
|
|
6ff910f26c | ||
|
|
013f145462 | ||
|
|
eea9696bd4 | ||
|
|
3f7e898c69 | ||
|
|
def3cf79f6 | ||
|
|
501a7199e0 | ||
|
|
bc6f1a1ff5 | ||
|
|
d06d3cabaf | ||
|
|
b040277220 | ||
|
|
f715a73f1b | ||
|
|
5be9a0e1df | ||
|
|
f7f7b8790e | ||
|
|
26021425e3 | ||
|
|
e35594f946 | ||
|
|
8a0b12b5ab | ||
|
|
60fff188a6 | ||
|
|
41181c9da1 | ||
|
|
8e485801c9 | ||
|
|
436afa51d7 | ||
|
|
0a29186ed6 | ||
|
|
101502cf4f | ||
|
|
f2b65f4a77 | ||
|
|
1816bad1ba | ||
|
|
9aee85d55e | ||
|
|
72f8705460 | ||
|
|
dffc292888 | ||
|
|
c95fb89c63 | ||
|
|
956b8000e4 | ||
|
|
a3fe15bfd0 | ||
|
|
1a770af47c | ||
|
|
3870ac3d7d | ||
|
|
b9ea9064c7 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.13.1
|
||||
pkgver=2.14.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.13.1
|
||||
pkgver=2.14.0
|
||||
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.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Python
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
|
||||
@@ -61,14 +61,22 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
# container builder can't see that store, so skip it here and let
|
||||
# `docker buildx build` fall back to the default docker driver, which
|
||||
# resolves the local base. The gha cache backend is also skipped (its
|
||||
# runtime token isn't exposed) since the docker driver can't use it.
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- 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.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- 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.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -41,6 +41,11 @@ jobs:
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
|
||||
- name: Run E2E tests (all browsers)
|
||||
run: task e2e:cross-browser
|
||||
|
||||
@@ -51,3 +56,16 @@ 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
|
||||
sign: false
|
||||
secrets: inherit
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- 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
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -28,6 +33,11 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -115,6 +125,20 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# Cache the Cargo registry and compiled dependency crates so the build
|
||||
# only recompiles the app crate. Written on main; PRs and the merge queue
|
||||
# restore from it.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
# Stable key shared across workflows so the nightly warmer.
|
||||
# rust-cache still appends OS + rustc + Cargo.lock.
|
||||
shared-key: tauri-${{ matrix.name }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -136,7 +160,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -163,7 +187,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -173,7 +197,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -208,7 +232,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -239,7 +263,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -260,7 +284,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -283,7 +307,7 @@ jobs:
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Preflight smctl
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -296,7 +320,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
|
||||
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -315,7 +339,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
@@ -332,7 +356,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
- name: Build Tauri app (signed)
|
||||
if: inputs.sign
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -366,6 +391,26 @@ jobs:
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: ${{ !inputs.sign }}
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: "0"
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here. AppImage runs in its own
|
||||
# continue-on-error step below so its persistent linuxdeploy
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
|
||||
# AppImage is decoupled so its linuxdeploy run gets a fresh process
|
||||
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
@@ -374,7 +419,7 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -389,7 +434,7 @@ jobs:
|
||||
args: --bundles appimage
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
run: |
|
||||
@@ -399,7 +444,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15'
|
||||
if: inputs.sign && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
@@ -437,7 +482,7 @@ jobs:
|
||||
# Verify the MSI AND the inner exe extracted from it are signed.
|
||||
# The inner exe is what gets installed on users' machines and what AV scans.
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
@@ -155,6 +155,19 @@ jobs:
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Base-changed PRs build the embedded image with the local docker driver
|
||||
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
|
||||
# store) resolves. A buildx container builder cannot see it and would try
|
||||
# to pull it from a registry, which fails. Single-platform, no gha cache.
|
||||
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
|
||||
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
|
||||
run: |
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
|
||||
--file ./${{ matrix.docker-rev }} \
|
||||
--tag stirling-pdf-embedded:pr-test \
|
||||
.
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }} (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
@@ -169,8 +182,10 @@ jobs:
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Fork PRs that did NOT change the base use the buildx container builder
|
||||
# (multi-platform + gha cache) against the published base image.
|
||||
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.5.1
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
@@ -49,6 +49,7 @@ app/core/src/main/resources/static/index.html
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
!app/core/src/main/resources/static/mobile-upload.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
|
||||
@@ -19,3 +19,11 @@ frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
|
||||
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
.github/workflows/tauri-build.yml:generic-api-key:402
|
||||
|
||||
+10
-2
@@ -25,21 +25,29 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||
# subtaskfile is included with `dir: .`. Local overrides in
|
||||
# .env.proprietary.local win over the committed .env.proprietary defaults.
|
||||
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
+48
-9
@@ -5,6 +5,11 @@ vars:
|
||||
# NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf
|
||||
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink"
|
||||
|
||||
# Minimum Java major the bundled JRE must be. Keep in sync with build.gradle
|
||||
# `modernJavaVersion` - the app JAR is compiled for this, so an older runtime
|
||||
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
|
||||
REQUIRED_JAVA: "25"
|
||||
|
||||
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
|
||||
JPDFIUM_PLATFORMS:
|
||||
sh: |
|
||||
@@ -102,6 +107,20 @@ tasks:
|
||||
jlink:
|
||||
desc: "Build backend JAR and create JLink runtime for Tauri"
|
||||
deps: [jlink:jar, jlink:runtime]
|
||||
# Runs after the runtime is in place. Lives here (not in jlink:runtime's
|
||||
# cmds) so it still fires when jlink:runtime short-circuits on its `status:`
|
||||
# check and reuses an existing runtime/jre - that reuse path is exactly how
|
||||
# a stale, too-old JRE slips through.
|
||||
cmds:
|
||||
- task: jlink:verify
|
||||
|
||||
jlink:verify:
|
||||
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
|
||||
dir: editor
|
||||
env:
|
||||
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
|
||||
cmds:
|
||||
- node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release
|
||||
|
||||
jlink:jar:
|
||||
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
|
||||
@@ -127,15 +146,35 @@ tasks:
|
||||
cmds:
|
||||
- rm -rf runtime/jre
|
||||
- mkdir -p runtime
|
||||
- |
|
||||
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
jlink \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
|
||||
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
|
||||
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
|
||||
#
|
||||
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
|
||||
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
|
||||
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [windows]
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [linux, darwin]
|
||||
# jlink emits its files mode 444 (read-only). Tauri's build-script
|
||||
# resource copier preserves source permissions when staging
|
||||
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
|
||||
|
||||
+108
-12
@@ -55,6 +55,15 @@ tasks:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:classifier-categories:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
sources:
|
||||
- editor/src/proprietary/data/classificationTaxonomy.ts
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -65,6 +74,7 @@ tasks:
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
- prepare:classifier-categories
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -130,9 +140,34 @@ tasks:
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
@@ -153,8 +188,10 @@ tasks:
|
||||
build:proprietary:
|
||||
desc: "Build for proprietary mode"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PREVIEW: '{{.PREVIEW | default ""}}'
|
||||
cmds:
|
||||
- npx vite build editor --mode proprietary
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
@@ -181,8 +218,26 @@ tasks:
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- npx vite build portal
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -250,17 +305,26 @@ tasks:
|
||||
cmds:
|
||||
- task: typecheck:proprietary
|
||||
|
||||
typecheck:_run:
|
||||
internal: true
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
cmds:
|
||||
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
|
||||
|
||||
typecheck:core:
|
||||
desc: "Typecheck core build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/core/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/core/tsconfig.json }
|
||||
|
||||
typecheck:proprietary:
|
||||
desc: "Typecheck proprietary build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
|
||||
|
||||
typecheck:saas:
|
||||
desc: "Typecheck SaaS build variant"
|
||||
@@ -268,7 +332,8 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/saas/tsconfig.json }
|
||||
|
||||
typecheck:desktop:
|
||||
desc: "Typecheck desktop build variant"
|
||||
@@ -276,37 +341,45 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/desktop/tsconfig.json }
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/cloud/tsconfig.json }
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/scripts/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: scripts/tsconfig.json }
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/scripts/tsconfig.json }
|
||||
|
||||
typecheck:prototypes:
|
||||
desc: "Typecheck prototypes build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
|
||||
|
||||
typecheck:portal:
|
||||
desc: "Typecheck developer portal build variant"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project portal/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: portal/tsconfig.json }
|
||||
|
||||
typecheck:shared:
|
||||
desc: "Typecheck the shared design system"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project shared/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: shared/tsconfig.json }
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
@@ -338,12 +411,23 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
classifier-categories:
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
|
||||
classifier-categories:check:
|
||||
desc: "Fail if the committed classifier categories JSON is out of date"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: classifier-categories:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
@@ -358,10 +442,22 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
- task: test:portal
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
|
||||
test:portal:
|
||||
desc: "Run portal tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root portal
|
||||
|
||||
test:watch:
|
||||
desc: "Run tests in watch mode"
|
||||
deps: [prepare]
|
||||
|
||||
+12
-38
@@ -4,8 +4,6 @@ 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: >-
|
||||
@@ -43,7 +41,9 @@ vars:
|
||||
':(exclude).github/workflows/*'
|
||||
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
|
||||
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}'
|
||||
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
|
||||
# which owns the version and caches the binary here.
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -84,22 +84,13 @@ tasks:
|
||||
- test -d scripts/pre-commit/.venv
|
||||
|
||||
clean:
|
||||
desc: "Remove the cache/build artifacts"
|
||||
desc: "Remove the cached gitleaks binary and the tool virtualenv"
|
||||
cmds:
|
||||
- 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-*"
|
||||
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
|
||||
platforms: [linux, darwin]
|
||||
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
|
||||
platforms: [windows]
|
||||
ignore_error: true
|
||||
|
||||
# Individual checks (hidden from `task --list`, but callable, e.g.
|
||||
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
|
||||
@@ -125,7 +116,7 @@ tasks:
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}})
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
|
||||
|
||||
gitleaks:
|
||||
deps: [gitleaks-bin]
|
||||
@@ -137,23 +128,6 @@ tasks:
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
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}}
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
cmds:
|
||||
- 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]
|
||||
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
|
||||
|
||||
+122
@@ -78,6 +78,110 @@ 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"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:all:saas:
|
||||
desc: "Start SaaS 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:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
cmds:
|
||||
@@ -124,6 +228,24 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
|
||||
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
|
||||
|
||||
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
|
||||
# Flip to true in app/.env.proprietary.local to test linking locally.
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
|
||||
# SaaS base URL the linked instance calls (register + entitlement).
|
||||
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
|
||||
@@ -1,3 +1,4 @@
|
||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||
# stays ignored via the root .gitignore.
|
||||
!.env.saas
|
||||
!.env.proprietary
|
||||
|
||||
@@ -29,13 +29,13 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
api "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
@@ -60,7 +60,7 @@ dependencies {
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
|
||||
api 'com.stirling:jpdfium:1.0.2'
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
|
||||
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
@@ -75,12 +75,12 @@ dependencies {
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
|
||||
}
|
||||
|
||||
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
|
||||
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
|
||||
|
||||
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
|
||||
}
|
||||
|
||||
+39
-16
@@ -206,6 +206,11 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Policies {
|
||||
/**
|
||||
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Absolute directories that policy folder input sources and output sinks may read from or
|
||||
* write to. Empty (the default) disables folder access entirely, so a policy can never be
|
||||
@@ -514,6 +519,14 @@ public class ApplicationProperties {
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
private LoginAgreement loginAgreement = new LoginAgreement();
|
||||
|
||||
@Data
|
||||
public static class LoginAgreement {
|
||||
private boolean enabled = false;
|
||||
private boolean showInAnonymousMode = true;
|
||||
private String fallbackText = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -582,7 +595,7 @@ public class ApplicationProperties {
|
||||
public static class SAML2 {
|
||||
private String provider;
|
||||
private Boolean enabled = false;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean autoCreateUser = true;
|
||||
private Boolean blockRegistration = false;
|
||||
private String registrationId = "stirling";
|
||||
|
||||
@@ -659,7 +672,7 @@ public class ApplicationProperties {
|
||||
private String issuer;
|
||||
private String clientId;
|
||||
@ToString.Exclude private String clientSecret;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean autoCreateUser = true;
|
||||
private Boolean blockRegistration = false;
|
||||
private String useAsUsername;
|
||||
private Collection<String> scopes = new ArrayList<>();
|
||||
@@ -730,7 +743,6 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
|
||||
/**
|
||||
@@ -834,8 +846,8 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Trust {
|
||||
private boolean serverAsAnchor = true;
|
||||
private boolean useSystemTrust = false;
|
||||
private boolean useMozillaBundle = false;
|
||||
private boolean useSystemTrust = true;
|
||||
private boolean useMozillaBundle = true;
|
||||
private boolean useAATL = false;
|
||||
private boolean useEUTL = false;
|
||||
}
|
||||
@@ -869,8 +881,8 @@ public class ApplicationProperties {
|
||||
public static class System {
|
||||
private String defaultLocale;
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean showUpdate = true;
|
||||
private boolean showUpdateOnlyAdmin = true;
|
||||
private boolean showSettingsWhenNoLogin = true;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
@@ -878,10 +890,10 @@ public class ApplicationProperties {
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Boolean enableDesktopInstallSlide = true;
|
||||
private Datasource datasource;
|
||||
private boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
private int maxDPI = 500;
|
||||
private boolean enableUrlToPDF;
|
||||
private Html html = new Html();
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
@@ -895,8 +907,9 @@ public class ApplicationProperties {
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
private ServerCertificate serverCertificate = new ServerCertificate();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
@@ -906,6 +919,16 @@ public class ApplicationProperties {
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ServerCertificate {
|
||||
private boolean enabled =
|
||||
true; // Enable server-side "Sign with Stirling-PDF" certificate
|
||||
private String organizationName = "Stirling PDF Inc";
|
||||
private int validity = 365; // Certificate validity in days
|
||||
private boolean regenerateOnStartup =
|
||||
false; // Generate a new certificate on each startup
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.enableAnalytics != null && this.enableAnalytics;
|
||||
}
|
||||
@@ -990,7 +1013,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Sharing {
|
||||
private boolean enabled = false;
|
||||
private boolean linkEnabled = false;
|
||||
private boolean linkEnabled = true;
|
||||
private boolean emailEnabled = false;
|
||||
private int linkExpirationDays = 3;
|
||||
}
|
||||
@@ -1164,7 +1187,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private boolean enabled;
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -1216,7 +1239,7 @@ public class ApplicationProperties {
|
||||
private boolean enableInvites = false;
|
||||
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
|
||||
private String host;
|
||||
private int port;
|
||||
private int port = 587;
|
||||
private String username;
|
||||
@ToString.Exclude private String password;
|
||||
private String from;
|
||||
@@ -1243,10 +1266,10 @@ public class ApplicationProperties {
|
||||
@ToString.Exclude private String botToken;
|
||||
private String botUsername;
|
||||
private String pipelineInboxFolder = "telegram";
|
||||
private Boolean customFolderSuffix = false;
|
||||
private Boolean enableAllowUserIDs = false;
|
||||
private Boolean customFolderSuffix = true;
|
||||
private Boolean enableAllowUserIDs = true;
|
||||
private List<Long> allowUserIDs = new ArrayList<>();
|
||||
private Boolean enableAllowChannelIDs = false;
|
||||
private Boolean enableAllowChannelIDs = true;
|
||||
private List<Long> allowChannelIDs = new ArrayList<>();
|
||||
private long processingTimeoutSeconds = 180;
|
||||
private long pollingIntervalMillis = 2000;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
|
||||
// enable/visibility come from the legal.loginAgreement settings.
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginAgreementService {
|
||||
|
||||
// Locale codes only: rejects path separators and dots so the value can never escape the
|
||||
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
|
||||
private static final Pattern LOCALE_PATTERN =
|
||||
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
|
||||
|
||||
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
|
||||
// from recursing far enough to overflow the stack on a hostile over-length input.
|
||||
private static final int MAX_LOCALE_LENGTH = 35;
|
||||
|
||||
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
|
||||
// wholesale into heap on every public request.
|
||||
private static final long MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public LoginAgreementService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return config().isEnabled();
|
||||
}
|
||||
|
||||
public boolean isShowInAnonymousMode() {
|
||||
return config().isShowInAnonymousMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the markdown to show for the requested language, falling back through the base
|
||||
* language, the configured default locale (and its base), then the configured fallbackText.
|
||||
* Returns an empty string when nothing is configured.
|
||||
*/
|
||||
public String resolveContent(String requestedLang) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
addLocaleCandidates(candidates, requestedLang);
|
||||
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
for (String candidate : candidates) {
|
||||
String content = readFileIfExists(candidate);
|
||||
if (content != null && !content.isBlank()) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
String fallback = config().getFallbackText();
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
|
||||
*/
|
||||
public String readRawForLocale(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
String content = readFileIfExists(locale);
|
||||
return content == null ? "" : content;
|
||||
}
|
||||
|
||||
/** Admin write. Blank content deletes the file so it falls back cleanly. */
|
||||
public void writeForLocale(String locale, String content) throws IOException {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("Invalid locale: " + locale);
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
Files.deleteIfExists(file);
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(file.getParent());
|
||||
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
|
||||
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
|
||||
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
|
||||
try {
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
file,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locales that currently have a markdown file, for the admin editor. */
|
||||
public Set<String> listLocalesWithContent() {
|
||||
Set<String> result = new TreeSet<>();
|
||||
Path dir = disclaimerDir();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return result;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".md"))
|
||||
.map(name -> name.substring(0, name.length() - ".md".length()))
|
||||
.filter(this::isValidLocale)
|
||||
.forEach(result::add);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed listing login agreement files", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ApplicationProperties.Legal.LoginAgreement config() {
|
||||
return applicationProperties.getLegal().getLoginAgreement();
|
||||
}
|
||||
|
||||
private Path disclaimerDir() {
|
||||
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
|
||||
}
|
||||
|
||||
private void addLocaleCandidates(List<String> out, String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
if (!out.contains(locale)) {
|
||||
out.add(locale);
|
||||
}
|
||||
String base = locale.split("[_-]", 2)[0];
|
||||
if (!base.equals(locale) && !out.contains(base)) {
|
||||
out.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
private String readFileIfExists(String locale) {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
|
||||
// planted symlink can't expose files outside the disclaimer dir via the public read.
|
||||
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.size(file) > MAX_FILE_BYTES) {
|
||||
log.warn(
|
||||
"Login agreement file for locale {} exceeds {} bytes; ignoring",
|
||||
locale,
|
||||
MAX_FILE_BYTES);
|
||||
return null;
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed reading login agreement file for locale {}", locale, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path resolveLocaleFile(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
Path dir = disclaimerDir();
|
||||
Path file = dir.resolve(locale + ".md").normalize();
|
||||
// Defence in depth: the regex already blocks separators, but confirm containment.
|
||||
if (!file.startsWith(dir)) {
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private boolean isValidLocale(String locale) {
|
||||
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
|
||||
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
|
||||
return locale != null
|
||||
&& locale.length() <= MAX_LOCALE_LENGTH
|
||||
&& LOCALE_PATTERN.matcher(locale).matches();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -17,6 +18,11 @@ import stirling.software.common.model.PdfMetadata;
|
||||
@Service
|
||||
public class PdfMetadataService {
|
||||
|
||||
/**
|
||||
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
|
||||
*/
|
||||
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String stirlingPDFLabel;
|
||||
private final UserServiceInterface userService;
|
||||
@@ -177,4 +183,14 @@ public class PdfMetadataService {
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
|
||||
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
|
||||
*/
|
||||
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
|
||||
pdf.setDocumentInformation(info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public String getLocalNetworkIp() {
|
||||
String routed = detectLocalIpViaDefaultRoute();
|
||||
if (routed != null) {
|
||||
return routed;
|
||||
}
|
||||
try {
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) return null;
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
return addr.getHostAddress();
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectBestSiteLocalIp(collectInterfaceInfo());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to detect local network IP", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String detectLocalIpViaDefaultRoute() {
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.connect(InetAddress.getByName("8.8.8.8"), 53);
|
||||
InetAddress local = socket.getLocalAddress();
|
||||
if (local instanceof Inet4Address
|
||||
&& !local.isAnyLocalAddress()
|
||||
&& !local.isLoopbackAddress()
|
||||
&& !local.isLinkLocalAddress()) {
|
||||
return local.getHostAddress();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Default-route IP detection failed; will scan interfaces", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
|
||||
List<NetworkInterfaceInfo> infos = new ArrayList<>();
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) {
|
||||
return infos;
|
||||
}
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
|
||||
List<String> siteLocalIpv4s = new ArrayList<>();
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
siteLocalIpv4s.add(addr.getHostAddress());
|
||||
}
|
||||
}
|
||||
if (siteLocalIpv4s.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] mac = iface.getHardwareAddress();
|
||||
infos.add(
|
||||
new NetworkInterfaceInfo(
|
||||
iface.getName(),
|
||||
iface.getDisplayName(),
|
||||
iface.getIndex(),
|
||||
iface.isUp(),
|
||||
iface.isLoopback(),
|
||||
iface.isPointToPoint(),
|
||||
iface.isVirtual(),
|
||||
mac != null && mac.length > 0,
|
||||
siteLocalIpv4s));
|
||||
} catch (SocketException e) {
|
||||
log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e);
|
||||
}
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
static String selectBestSiteLocalIp(List<NetworkInterfaceInfo> interfaces) {
|
||||
return interfaces.stream()
|
||||
.filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual())
|
||||
.filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName()))
|
||||
.flatMap(
|
||||
i ->
|
||||
i.siteLocalIpv4s().stream()
|
||||
.map(
|
||||
ip ->
|
||||
new ScoredAddress(
|
||||
ip,
|
||||
scoreInterface(i, ip),
|
||||
i.index())))
|
||||
.max(
|
||||
Comparator.comparingInt(ScoredAddress::score)
|
||||
.thenComparing(
|
||||
Comparator.comparingInt(ScoredAddress::interfaceIndex)
|
||||
.reversed()))
|
||||
.map(ScoredAddress::ip)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static int scoreInterface(NetworkInterfaceInfo iface, String ip) {
|
||||
int score = 0;
|
||||
if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) {
|
||||
score += 100;
|
||||
}
|
||||
if (iface.hasHardwareAddress()) {
|
||||
score += 20;
|
||||
}
|
||||
if (ip.startsWith("192.168.")) {
|
||||
score += 30;
|
||||
} else if (ip.startsWith("10.")) {
|
||||
score += 20;
|
||||
} else {
|
||||
score += 5;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
static boolean isLikelyVirtualInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
String[] namePrefixes = {
|
||||
"tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl",
|
||||
"llw"
|
||||
};
|
||||
for (String prefix : namePrefixes) {
|
||||
if (n.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] displayMarkers = {
|
||||
"vmware",
|
||||
"virtualbox",
|
||||
"virtual box",
|
||||
"vbox",
|
||||
"hyper-v",
|
||||
"hyperv",
|
||||
"vethernet",
|
||||
"windows subsystem for linux",
|
||||
"wsl",
|
||||
"docker",
|
||||
"tap-windows",
|
||||
"tunnel",
|
||||
"vpn",
|
||||
"zerotier",
|
||||
"tailscale",
|
||||
"bluetooth",
|
||||
"teredo",
|
||||
"isatap",
|
||||
"loopback",
|
||||
"pseudo",
|
||||
"virtual"
|
||||
};
|
||||
for (String marker : displayMarkers) {
|
||||
if (d.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isLikelyPhysicalInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
return n.startsWith("eth")
|
||||
|| n.startsWith("en")
|
||||
|| n.startsWith("wl")
|
||||
|| n.startsWith("em")
|
||||
|| d.contains("ethernet")
|
||||
|| d.contains("wi-fi")
|
||||
|| d.contains("wifi")
|
||||
|| d.contains("wireless");
|
||||
}
|
||||
|
||||
record NetworkInterfaceInfo(
|
||||
String name,
|
||||
String displayName,
|
||||
int index,
|
||||
boolean up,
|
||||
boolean loopback,
|
||||
boolean pointToPoint,
|
||||
boolean virtual,
|
||||
boolean hasHardwareAddress,
|
||||
List<String> siteLocalIpv4s) {}
|
||||
|
||||
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
|
||||
}
|
||||
|
||||
@@ -244,10 +244,7 @@ public class SvgSanitizer {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized.startsWith("http://")
|
||||
|| normalized.startsWith("https://")
|
||||
|| normalized.startsWith("//")
|
||||
|| normalized.startsWith("file:");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isUrlAllowed(String url) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
import java.awt.geom.Point2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.pdf.parser.PageImageLocator.ImageBox;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PageImageLocator}. PDFs are built in memory with PDFBox so each test is
|
||||
* deterministic and needs no fixtures or native libraries. The locator transforms the image unit
|
||||
* square through the CTM, so an image drawn at {@code (x, y)} with size {@code (w, h)} must yield
|
||||
* the box {@code (x, y, x+w, y+h)}.
|
||||
*/
|
||||
class PageImageLocatorTest {
|
||||
|
||||
/** A tiny opaque raster; pixel content is irrelevant, only its placement matters. */
|
||||
private static PDImageXObject tinyImage(PDDocument doc) throws Exception {
|
||||
BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
|
||||
return LosslessFactory.createFromImage(doc, img);
|
||||
}
|
||||
|
||||
/** Builds a one-page PDF that draws one image at the given placement. */
|
||||
private static byte[] pdfWithImageAt(float x, float y, float w, float h) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = tinyImage(doc);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, x, y, w, h);
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("drawImage bounding boxes")
|
||||
class DrawImageBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("a single image yields one box with the page index and CTM-derived bounds")
|
||||
void singleImageBox() throws Exception {
|
||||
byte[] pdf = pdfWithImageAt(100f, 200f, 50f, 80f);
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
|
||||
locator.processPage(doc.getPage(0));
|
||||
|
||||
List<ImageBox> boxes = locator.getImageBoxes();
|
||||
assertThat(boxes).hasSize(1);
|
||||
ImageBox box = boxes.get(0);
|
||||
assertThat(box.pageIndex()).isZero();
|
||||
assertThat(box.x1()).isCloseTo(100f, within(0.5f));
|
||||
assertThat(box.y1()).isCloseTo(200f, within(0.5f));
|
||||
assertThat(box.x2()).isCloseTo(150f, within(0.5f));
|
||||
assertThat(box.y2()).isCloseTo(280f, within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the supplied page index is stored on every box")
|
||||
void pageIndexStored() throws Exception {
|
||||
byte[] pdf = pdfWithImageAt(10f, 10f, 20f, 20f);
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 7);
|
||||
locator.processPage(doc.getPage(0));
|
||||
assertThat(locator.getImageBoxes().get(0).pageIndex()).isEqualTo(7);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two images on one page yield two boxes")
|
||||
void twoImages() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = tinyImage(doc);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 50f, 50f, 30f, 30f);
|
||||
cs.drawImage(image, 200f, 400f, 60f, 40f);
|
||||
}
|
||||
byte[] pdf = save(doc);
|
||||
try (PDDocument reopened = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
|
||||
locator.processPage(reopened.getPage(0));
|
||||
assertThat(locator.getImageBoxes()).hasSize(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a page with no images yields no boxes")
|
||||
void noImages() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
byte[] pdf = save(doc);
|
||||
try (PDDocument reopened = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
|
||||
locator.processPage(reopened.getPage(0));
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getImageBoxes is empty before any page is processed")
|
||||
void emptyBeforeProcessing() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("path operation no-ops")
|
||||
class PathNoOps {
|
||||
|
||||
private PageImageLocator newLocator() {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
return new PageImageLocator(page, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moveTo updates the current point")
|
||||
void moveToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.moveTo(12f, 34f);
|
||||
Point2D current = locator.getCurrentPoint();
|
||||
assertThat(current.getX()).isEqualTo(12d);
|
||||
assertThat(current.getY()).isEqualTo(34d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lineTo updates the current point")
|
||||
void lineToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.lineTo(5f, 6f);
|
||||
assertThat(locator.getCurrentPoint().getX()).isEqualTo(5d);
|
||||
assertThat(locator.getCurrentPoint().getY()).isEqualTo(6d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("curveTo updates the current point to the final control point")
|
||||
void curveToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.curveTo(1f, 1f, 2f, 2f, 9f, 8f);
|
||||
assertThat(locator.getCurrentPoint().getX()).isEqualTo(9d);
|
||||
assertThat(locator.getCurrentPoint().getY()).isEqualTo(8d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rectangle, clip, path and shading operations are no-ops that do not throw")
|
||||
void otherOpsDoNotThrow() {
|
||||
PageImageLocator locator = newLocator();
|
||||
Point2D p = new Point2D.Float(0f, 0f);
|
||||
// None of these record anything or alter state; they must simply not throw.
|
||||
locator.appendRectangle(p, p, p, p);
|
||||
locator.clip(0);
|
||||
locator.closePath();
|
||||
locator.endPath();
|
||||
locator.strokePath();
|
||||
locator.fillPath(0);
|
||||
locator.fillAndStrokePath(0);
|
||||
locator.shadingFill(COSName.getPDFName("Sh0"));
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class AppConfigTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockEnvironment env;
|
||||
private AppConfig appConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
env = new MockEnvironment();
|
||||
appConfig = new AppConfig(env, applicationProperties);
|
||||
ReflectionTestUtils.setField(appConfig, "contextPath", "/");
|
||||
ReflectionTestUtils.setField(appConfig, "serverPort", "8080");
|
||||
ReflectionTestUtils.setField(appConfig, "v2Enabled", true);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Value-backed getters and simple beans")
|
||||
class SimpleBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("getter fields reflect injected @Value values")
|
||||
void valueGetters() {
|
||||
assertThat(appConfig.getContextPath()).isEqualTo("/");
|
||||
assertThat(appConfig.getServerPort()).isEqualTo("8080");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2Enabled bean mirrors the field")
|
||||
void v2EnabledBean() {
|
||||
assertThat(appConfig.v2Enabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constant beans return fixed values")
|
||||
void constants() {
|
||||
assertThat(appConfig.appName()).isEqualTo("Stirling PDF");
|
||||
assertThat(appConfig.homeText()).isEqualTo("null");
|
||||
assertThat(appConfig.contextPath("/ctx")).isEqualTo("/ctx");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appVersion resolves from version.properties on classpath")
|
||||
void appVersion() {
|
||||
assertThat(appConfig.appVersion()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("StirlingPDFLabel embeds version")
|
||||
void stirlingLabel() {
|
||||
assertThat(appConfig.stirlingPDFLabel()).startsWith("Stirling-PDF v");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Beans backed by ApplicationProperties")
|
||||
class PropertyBackedBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("loginEnabled reflects security flag")
|
||||
void loginEnabled() {
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
assertThat(appConfig.loginEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backendUrl falls back to localhost when unset")
|
||||
void backendUrlFallback() {
|
||||
assertThat(appConfig.getBackendUrl()).isEqualTo("http://localhost");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backendUrl returns configured value when present")
|
||||
void backendUrlConfigured() {
|
||||
applicationProperties.getSystem().setBackendUrl("https://api.example.com");
|
||||
assertThat(appConfig.getBackendUrl()).isEqualTo("https://api.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("languages bean returns configured languages list")
|
||||
void languages() {
|
||||
applicationProperties.getUi().setLanguages(List.of("en", "de"));
|
||||
assertThat(appConfig.languages()).containsExactly("en", "de");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("navBarText falls back to Stirling PDF when unset")
|
||||
void navBarTextFallback() {
|
||||
assertThat(appConfig.navBarText()).isEqualTo("Stirling PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("navBarText returns configured value")
|
||||
void navBarTextConfigured() {
|
||||
applicationProperties.getUi().setAppNameNavbar("My PDF");
|
||||
assertThat(appConfig.navBarText()).isEqualTo("My PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enableAlphaFunctionality reflects system flag")
|
||||
void alphaFunctionality() {
|
||||
applicationProperties.getSystem().setEnableAlphaFunctionality(true);
|
||||
assertThat(appConfig.enableAlphaFunctionality()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legal text beans return configured values")
|
||||
void legalBeans() {
|
||||
var legal = applicationProperties.getLegal();
|
||||
legal.setTermsAndConditions("terms");
|
||||
legal.setPrivacyPolicy("privacy");
|
||||
legal.setCookiePolicy("cookie");
|
||||
legal.setImpressum("impressum");
|
||||
legal.setAccessibilityStatement("a11y");
|
||||
assertThat(appConfig.termsAndConditions()).isEqualTo("terms");
|
||||
assertThat(appConfig.privacyPolicy()).isEqualTo("privacy");
|
||||
assertThat(appConfig.cookiePolicy()).isEqualTo("cookie");
|
||||
assertThat(appConfig.impressum()).isEqualTo("impressum");
|
||||
assertThat(appConfig.accessibilityStatement()).isEqualTo("a11y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsPrompt true when enableAnalytics null")
|
||||
void analyticsPrompt() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(null);
|
||||
assertThat(appConfig.analyticsPrompt()).isTrue();
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
assertThat(appConfig.analyticsPrompt()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsEnabled true when premium enabled regardless of system flag")
|
||||
void analyticsEnabledViaPremium() {
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
assertThat(appConfig.analyticsEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsEnabled reflects system flag when premium disabled")
|
||||
void analyticsEnabledViaSystem() {
|
||||
applicationProperties.getPremium().setEnabled(false);
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
assertThat(appConfig.analyticsEnabled()).isTrue();
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.FALSE);
|
||||
assertThat(appConfig.analyticsEnabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("scarf and posthog beans reflect derived flags")
|
||||
void scarfAndPosthog() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
applicationProperties.getSystem().setEnableScarf(Boolean.TRUE);
|
||||
applicationProperties.getSystem().setEnablePosthog(Boolean.TRUE);
|
||||
assertThat(appConfig.scarfEnabled()).isTrue();
|
||||
assertThat(appConfig.posthogEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uuid bean returns generated UUID")
|
||||
void uuidBean() {
|
||||
applicationProperties.getAutomaticallyGenerated().setUUID("abc-123");
|
||||
assertThat(appConfig.uuid()).isEqualTo("abc-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("typed config beans return live nested instances")
|
||||
void typedConfigBeans() {
|
||||
assertThat(appConfig.security()).isSameAs(applicationProperties.getSecurity());
|
||||
assertThat(appConfig.oAuth2())
|
||||
.isSameAs(applicationProperties.getSecurity().getOauth2());
|
||||
assertThat(appConfig.premium()).isSameAs(applicationProperties.getPremium());
|
||||
assertThat(appConfig.system()).isSameAs(applicationProperties.getSystem());
|
||||
assertThat(appConfig.datasource())
|
||||
.isSameAs(applicationProperties.getSystem().getDatasource());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Profile-default and environment beans")
|
||||
class ProfileAndEnvBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("default-profile license beans return community defaults")
|
||||
void licenseDefaults() {
|
||||
assertThat(appConfig.runningProOrHigher()).isFalse();
|
||||
assertThat(appConfig.runningEnterprise()).isFalse();
|
||||
assertThat(appConfig.licenseType()).isEqualTo("NORMAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("activeSecurity reflects classpath presence of SecurityConfiguration")
|
||||
void activeSecurity() {
|
||||
// Just exercise the branch; result depends on classpath, assert it does not throw.
|
||||
boolean present = appConfig.missingActiveSecurity();
|
||||
assertThat(present).isIn(true, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rateLimit parses system property")
|
||||
void rateLimitProperty() {
|
||||
String prev = System.getProperty("rateLimit");
|
||||
try {
|
||||
System.setProperty("rateLimit", "true");
|
||||
assertThat(appConfig.rateLimit()).isTrue();
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
System.clearProperty("rateLimit");
|
||||
} else {
|
||||
System.setProperty("rateLimit", prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runningInDocker false outside container")
|
||||
void runningInDocker() {
|
||||
// CI/test host is not a container with /.dockerenv.
|
||||
assertThat(appConfig.runningInDocker()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("configDirMounted defaults to true when not in docker")
|
||||
void configDirMounted() {
|
||||
assertThat(appConfig.isRunningInDockerWithConfig()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("directoryFilter accepts files and rejects processing dirs")
|
||||
void directoryFilter(@org.junit.jupiter.api.io.TempDir Path tempDir) throws Exception {
|
||||
Predicate<Path> filter = appConfig.processOnlyFiles();
|
||||
Path file = Files.createFile(tempDir.resolve("a.txt"));
|
||||
Path normalDir = Files.createDirectory(tempDir.resolve("normal"));
|
||||
Path processingDir = Files.createDirectory(tempDir.resolve("processing"));
|
||||
assertThat(filter.test(file)).isTrue();
|
||||
assertThat(filter.test(normalDir)).isTrue();
|
||||
assertThat(filter.test(processingDir)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("machineType returns Server-jar in plain test environment")
|
||||
void machineTypeServerJar() {
|
||||
assertThat(appConfig.determineMachineType()).isEqualTo("Server-jar");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("machineType returns a Client-* variant when BROWSER_OPEN set")
|
||||
void machineTypeClient() {
|
||||
env.setProperty("BROWSER_OPEN", "true");
|
||||
assertThat(appConfig.determineMachineType()).startsWith("Client-");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
class ConfigInitializerMoreTest {
|
||||
|
||||
private static final LoadSettings LOAD_SETTINGS =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
// Template after the enterpriseEdition -> premium rename.
|
||||
private static final String PREMIUM_TEMPLATE =
|
||||
"""
|
||||
premium:
|
||||
enabled: false
|
||||
key: 0000
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
""";
|
||||
|
||||
@Nested
|
||||
@DisplayName("migrateEnterpriseEditionToPremium")
|
||||
class EnterpriseMigration {
|
||||
|
||||
@Test
|
||||
@DisplayName("carries legacy enterpriseEdition values forward into premium block")
|
||||
void migratesLegacyEnterpriseValues() throws Exception {
|
||||
String legacy =
|
||||
"""
|
||||
enterpriseEdition:
|
||||
enabled: true
|
||||
key: ABC-123
|
||||
SSOAutoLogin: true
|
||||
CustomMetadata:
|
||||
autoUpdateMetadata: true
|
||||
author: alice
|
||||
creator: bob
|
||||
producer: carol
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
|
||||
|
||||
invokeMigrate(existing, template);
|
||||
|
||||
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("true");
|
||||
assertThat(template.getValueByExactKeyPath("premium", "key")).isEqualTo("ABC-123");
|
||||
assertThat(template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"))
|
||||
.isEqualTo("true");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium",
|
||||
"proFeatures",
|
||||
"customMetadata",
|
||||
"autoUpdateMetadata"))
|
||||
.isEqualTo("true");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"))
|
||||
.isEqualTo("alice");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "creator"))
|
||||
.isEqualTo("bob");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "producer"))
|
||||
.isEqualTo("carol");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no legacy enterpriseEdition block leaves template defaults intact")
|
||||
void noLegacyKeysIsNoOp() throws Exception {
|
||||
String noEnterprise =
|
||||
"""
|
||||
security:
|
||||
enableLogin: false
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, noEnterprise);
|
||||
|
||||
invokeMigrate(existing, template);
|
||||
|
||||
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("false");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"))
|
||||
.isEqualTo("username");
|
||||
}
|
||||
|
||||
private void invokeMigrate(YamlHelper yaml, YamlHelper template) throws Exception {
|
||||
var method =
|
||||
ConfigInitializer.class.getDeclaredMethod(
|
||||
"migrateEnterpriseEditionToPremium",
|
||||
YamlHelper.class,
|
||||
YamlHelper.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(new ConfigInitializer(), yaml, template);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ensureConfigExists - create branch (template absent on common classpath)")
|
||||
class EnsureConfigCreateBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("no settings file -> attempts create, fails fast when template missing")
|
||||
void createWithoutTemplateThrows(@TempDir Path tempDir) throws Exception {
|
||||
Path settings = tempDir.resolve("configs").resolve("settings.yml");
|
||||
Path custom = tempDir.resolve("configs").resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath)
|
||||
.thenReturn(settings.toString());
|
||||
mocked.when(InstallationPathConfig::getCustomSettingsPath)
|
||||
.thenReturn(custom.toString());
|
||||
|
||||
// settings.yml.template is packaged in the core module, not common, so the
|
||||
// create branch must surface a FileNotFoundException here.
|
||||
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
|
||||
.isInstanceOf(FileNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("short existing settings file is backed up before recreate attempt")
|
||||
void shortFileIsBackedUp(@TempDir Path tempDir) throws Exception {
|
||||
Path configDir = Files.createDirectories(tempDir.resolve("configs"));
|
||||
Path settings = configDir.resolve("settings.yml");
|
||||
Path custom = configDir.resolve("custom_settings.yml");
|
||||
// Fewer than MIN_SETTINGS_FILE_LINES (31) lines triggers the recreate path.
|
||||
Files.writeString(settings, "a: 1\nb: 2\n");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath)
|
||||
.thenReturn(settings.toString());
|
||||
mocked.when(InstallationPathConfig::getCustomSettingsPath)
|
||||
.thenReturn(custom.toString());
|
||||
|
||||
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
|
||||
.isInstanceOf(FileNotFoundException.class);
|
||||
}
|
||||
|
||||
// Original was moved to a timestamped .bak before the failed recreate.
|
||||
try (Stream<Path> files = Files.list(configDir)) {
|
||||
assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak")))
|
||||
.isTrue();
|
||||
}
|
||||
assertThat(Files.exists(settings)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package stirling.software.common.pdf;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.text.PageText;
|
||||
import stirling.software.jpdfium.text.Table;
|
||||
import stirling.software.jpdfium.text.TextChar;
|
||||
import stirling.software.jpdfium.text.TextLine;
|
||||
import stirling.software.jpdfium.text.TextWord;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link PdfMarkdownConverter} not covered by {@link
|
||||
* PdfMarkdownConverterTest}: the visible-for-testing column-range detector across a range of
|
||||
* geometries, the package-private extraction helpers, and the full conversion of the wrapped-cell
|
||||
* fixture (only run under a disabled accuracy test in the sibling suite).
|
||||
*/
|
||||
class PdfMarkdownConverterMoreTest {
|
||||
|
||||
@TempDir Path tmp;
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/** A word occupying [x, x+width] on baseline y; chars are synthetic so text length is real. */
|
||||
private static TextWord word(String text, float x, float width) {
|
||||
List<TextChar> chars = new ArrayList<>();
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
chars.add(
|
||||
new TextChar(
|
||||
i,
|
||||
text.charAt(i),
|
||||
x,
|
||||
0f,
|
||||
width / Math.max(1, text.length()),
|
||||
10f,
|
||||
"Helvetica",
|
||||
10f));
|
||||
}
|
||||
return new TextWord(chars, x, 0f, width, 10f);
|
||||
}
|
||||
|
||||
/** A single-line row built from the given words, spanning their full x-range. */
|
||||
private static TextLine row(float y, TextWord... words) {
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxX = -Float.MAX_VALUE;
|
||||
for (TextWord w : words) {
|
||||
minX = Math.min(minX, w.x());
|
||||
maxX = Math.max(maxX, w.x() + w.width());
|
||||
}
|
||||
return new TextLine(List.of(words), minX, y, maxX - minX, 10f);
|
||||
}
|
||||
|
||||
/** Copies a classpath fixture into the temp dir and returns its path. */
|
||||
private Path fixture(String name) throws IOException {
|
||||
Path dest = tmp.resolve(name);
|
||||
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + name)) {
|
||||
assertThat(in).as("fixture on classpath: " + name).isNotNull();
|
||||
Files.copy(in, dest);
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
// ---- findColumnRangesFromLines -----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("findColumnRangesFromLines")
|
||||
class ColumnRanges {
|
||||
|
||||
@Test
|
||||
@DisplayName("two well-separated bands are detected as two columns")
|
||||
void twoColumns() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 4; r++) {
|
||||
float y = 400f - r * 12f;
|
||||
rows.add(row(y, word("left", 50f, 40f), word("right", 190f, 40f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(2);
|
||||
// First band starts near 50, second near 190.
|
||||
assertThat(cols.get(0)[0]).isLessThan(cols.get(1)[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two bands within a narrow gutter merge into one column")
|
||||
void narrowGutterMerges() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 4; r++) {
|
||||
float y = 400f - r * 12f;
|
||||
// Gap of ~10pt is far below the merge threshold for 40pt-wide words.
|
||||
rows.add(row(y, word("aa", 50f, 40f), word("bb", 100f, 40f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single occupied band yields one column (trailing-band flush)")
|
||||
void singleColumn() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 3; r++) {
|
||||
rows.add(row(400f - r * 12f, word("word", 50f, 60f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
assertThat(cols.get(0)[0]).isCloseTo(50f, org.assertj.core.api.Assertions.within(2f));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rows with no words produce no columns")
|
||||
void noWordsNoColumns() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 3; r++) {
|
||||
rows.add(new TextLine(List.of(), 0f, 400f - r * 12f, 0f, 10f));
|
||||
}
|
||||
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(rows)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty row list produces no columns")
|
||||
void emptyInput() {
|
||||
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a sparsely-covered band below the support threshold is dropped")
|
||||
void sparseBandDropped() {
|
||||
// Five rows fill the left band; only one fills a far-right band, which is below the
|
||||
// 35%-of-rows support floor and so is not reported as a column.
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 5; r++) {
|
||||
rows.add(row(400f - r * 12f, word("left", 50f, 40f)));
|
||||
}
|
||||
rows.add(row(320f, word("left", 50f, 40f), word("rareoutlier", 400f, 60f)));
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- package-private extraction helpers ---------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extraction helpers")
|
||||
class ExtractionHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("extractAllPageText returns one PageText per page")
|
||||
void extractAllPageText() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<PageText> pages = new PdfMarkdownConverter().extractAllPageText(doc);
|
||||
assertThat(pages).isNotNull();
|
||||
assertThat(pages).hasSize(doc.pageCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTables returns a non-null list for the first page")
|
||||
void extractTables() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<Table> tables = new PdfMarkdownConverter().extractTables(doc, 0);
|
||||
assertThat(tables).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renderTables maps each extracted table to a markdown string")
|
||||
void renderTables() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
PdfMarkdownConverter converter = new PdfMarkdownConverter();
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<Table> tables = converter.extractTables(doc, 0);
|
||||
List<String> rendered = converter.renderTables(tables);
|
||||
assertThat(rendered).isNotNull();
|
||||
assertThat(rendered).hasSameSizeAs(tables);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renderTables on an empty table list returns an empty list")
|
||||
void renderTablesEmpty() {
|
||||
assertThat(new PdfMarkdownConverter().renderTables(List.of())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- full conversion of additional fixtures -----------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convert full pipeline")
|
||||
class ConvertPipeline {
|
||||
|
||||
@Test
|
||||
@DisplayName("wrapped-cell expense report converts without throwing and yields content")
|
||||
void wrappedCellFixture() throws IOException {
|
||||
Path pdf = fixture("wrapped-cell-test_expense-report.pdf");
|
||||
String md;
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
md = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
assertThat(md).isNotNull();
|
||||
assertThat(md).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converting a fixture twice is deterministic")
|
||||
void deterministic() throws IOException {
|
||||
Path pdf = fixture("multi-column-test_lorem.pdf");
|
||||
String first;
|
||||
String second;
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
first = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
second = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the many-tables stress fixture converts without throwing")
|
||||
void manyTablesFixture() throws IOException {
|
||||
Path pdf = fixture("many-tables-test_stress.pdf");
|
||||
assertDoesNotThrow(
|
||||
() -> {
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
return new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.pdf;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.jpdfium.text.Table;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TableRenderer}. Tables are built directly from the {@link Table} record so
|
||||
* the renderer can be exercised without any PDF parsing, fixtures, or native calls.
|
||||
*/
|
||||
class TableRendererTest {
|
||||
|
||||
/** Builds a Table from raw rows; geometry is irrelevant to rendering so it is set to zero. */
|
||||
private static Table table(List<List<String>> rows) {
|
||||
return new Table(rows, 0f, 0f, 0f, 0f);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Degenerate tables")
|
||||
class Degenerate {
|
||||
|
||||
@Test
|
||||
@DisplayName("zero rows renders the empty string")
|
||||
void zeroRows() {
|
||||
assertThat(TableRenderer.render(table(List.of()))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single row with one column has no separator and is a plain line")
|
||||
void singleRowOneColumn() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("only"))));
|
||||
assertThat(md).isEqualTo("only");
|
||||
assertThat(md).doesNotContain("|");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single row with several columns becomes newline-separated plain lines")
|
||||
void singleRowManyColumns() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("a", "b", "c"))));
|
||||
// No separator row is possible with a single row, so cells are emitted as lines.
|
||||
assertThat(md).isEqualTo("a\nb\nc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-row cell content is trimmed and escaped")
|
||||
void singleRowTrimsAndEscapes() {
|
||||
String md = TableRenderer.render(table(List.of(List.of(" a|b "))));
|
||||
assertThat(md).isEqualTo("a\\|b");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("GFM rendering")
|
||||
class GfmRendering {
|
||||
|
||||
@Test
|
||||
@DisplayName("two rows produce a header, a separator and a data row")
|
||||
void headerSeparatorData() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(List.of(List.of("Name", "Age"), List.of("Alice", "30"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(3);
|
||||
assertThat(lines[0]).startsWith("|").contains("Name").contains("Age");
|
||||
// Separator row is made only of pipes and dashes.
|
||||
assertThat(lines[1].chars().allMatch(c -> c == '|' || c == '-')).isTrue();
|
||||
assertThat(lines[2]).contains("Alice").contains("30");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("column widths grow to fit the widest cell in each column")
|
||||
void columnWidthsFitContent() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(
|
||||
List.of(
|
||||
List.of("h", "header2"),
|
||||
List.of("averylongvalue", "x"))));
|
||||
String[] lines = md.split("\n");
|
||||
// Every rendered row (header, separator, data) is the same total width.
|
||||
int width = lines[0].length();
|
||||
for (String line : lines) {
|
||||
assertThat(line.length()).isEqualTo(width);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("minimum column width of three dashes is honoured for tiny cells")
|
||||
void minimumWidthThree() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("a", "b"), List.of("c", "d"))));
|
||||
String separator = md.split("\n")[1];
|
||||
// Each column is padded to a minimum of 3, fenced by a dash either side: |-----|-----|.
|
||||
assertThat(separator).isEqualTo("|-----|-----|");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pipe characters in cells are escaped in every rendered row")
|
||||
void escapesPipes() {
|
||||
String md =
|
||||
TableRenderer.render(table(List.of(List.of("a|b", "c"), List.of("d", "e|f"))));
|
||||
// Two literal pipes escaped; the structural pipes are not.
|
||||
assertThat(md).contains("a\\|b").contains("e\\|f");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cells are trimmed before measuring and rendering")
|
||||
void trimsCells() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(List.of(List.of(" Name ", " Age "), List.of("Al", "30"))));
|
||||
assertThat(md).contains("| Name").contains("Age ");
|
||||
assertThat(md).doesNotContain(" Name ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("three rows emit two data rows after the separator")
|
||||
void multipleDataRows() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(
|
||||
List.of(
|
||||
List.of("c1", "c2"),
|
||||
List.of("a", "b"),
|
||||
List.of("x", "y"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(4);
|
||||
assertThat(lines[2]).contains("a").contains("b");
|
||||
assertThat(lines[3]).contains("x").contains("y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a short trailing row is padded out to the column count from asGrid")
|
||||
void shortRowPaddedByGrid() {
|
||||
// colCount comes from the first row; a shorter later row is padded with empty cells by
|
||||
// Table.asGrid, so rendering must not throw and the grid stays rectangular.
|
||||
String md =
|
||||
TableRenderer.render(table(List.of(List.of("a", "b", "c"), List.of("only"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(3);
|
||||
int width = lines[0].length();
|
||||
for (String line : lines) {
|
||||
assertThat(line.length()).isEqualTo(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.io.MemoryUsageSetting;
|
||||
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class CustomPDFDocumentFactoryMoreTest {
|
||||
|
||||
private CustomPDFDocumentFactory factory;
|
||||
private byte[] basePdfBytes;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
try (InputStream is = getClass().getResourceAsStream("/example.pdf")) {
|
||||
basePdfBytes = is.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("null-argument guards")
|
||||
class NullGuards {
|
||||
|
||||
@Test
|
||||
@DisplayName("each load overload rejects null with IllegalArgumentException")
|
||||
void nullArguments() {
|
||||
assertThatThrownBy(() -> factory.load((File) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((Path) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((byte[]) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((InputStream) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((InputStream) null, "pw"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cache strategy selection (public overload)")
|
||||
class CacheStrategy {
|
||||
|
||||
@Test
|
||||
@DisplayName("getStreamCacheFunction returns a non-null function for each size band")
|
||||
void cacheFunctionPerBand() {
|
||||
StreamCacheCreateFunction small = factory.getStreamCacheFunction(1024);
|
||||
StreamCacheCreateFunction mixed = factory.getStreamCacheFunction(20L * 1024 * 1024);
|
||||
StreamCacheCreateFunction large = factory.getStreamCacheFunction(60L * 1024 * 1024);
|
||||
assertThat(small).isNotNull();
|
||||
assertThat(mixed).isNotNull();
|
||||
assertThat(large).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("create and round-trip helpers")
|
||||
class CreateAndRoundTrip {
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocument(MemoryUsageSetting) sets default metadata")
|
||||
void createWithMemorySetting() throws IOException {
|
||||
PdfMetadataService svc = mock(PdfMetadataService.class);
|
||||
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
|
||||
try (PDDocument doc = f.createNewDocument(MemoryUsageSetting.setupMainMemoryOnly())) {
|
||||
assertThat(doc).isNotNull();
|
||||
verify(svc).setDefaultMetadata(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loadToBytes(byte[]) round-trips a loadable PDF")
|
||||
void loadToBytesFromArray() throws IOException {
|
||||
byte[] out = factory.loadToBytes(basePdfBytes);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocumentBasedOnOldDocument(byte[]) produces a fresh document")
|
||||
void newDocFromOldBytes() throws IOException {
|
||||
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(basePdfBytes)) {
|
||||
assertThat(doc).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocumentBasedOnOldDocument(File) produces a fresh document")
|
||||
void newDocFromOldFile(@TempDir Path tempDir) throws IOException {
|
||||
File f = Files.write(tempDir.resolve("old.pdf"), basePdfBytes).toFile();
|
||||
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(f)) {
|
||||
assertThat(doc).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("read-only and password handling")
|
||||
class ReadOnlyAndPassword {
|
||||
|
||||
@Test
|
||||
@DisplayName("read-only load from file skips post-processing")
|
||||
void readOnlyFromFile(@TempDir Path tempDir) throws IOException {
|
||||
PdfMetadataService svc = mock(PdfMetadataService.class);
|
||||
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
|
||||
File file = Files.write(tempDir.resolve("ro.pdf"), basePdfBytes).toFile();
|
||||
try (PDDocument doc = f.load(file, true)) {
|
||||
assertThat(doc).isNotNull();
|
||||
org.mockito.Mockito.verify(svc, org.mockito.Mockito.never())
|
||||
.setDefaultMetadata(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encrypted PDF is decrypted on the default (non-read-only) load path")
|
||||
void encryptedPdfDecrypted() throws IOException {
|
||||
byte[] encrypted = buildEncryptedPdf("ownerpw", "userpw");
|
||||
// load(InputStream, password) drives removePassword + setAllSecurityToBeRemoved so the
|
||||
// returned document can be re-saved with no password set.
|
||||
byte[] decryptedSaved;
|
||||
try (PDDocument doc =
|
||||
factory.load(new ByteArrayInputStream(encrypted), "userpw", false)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
decryptedSaved = factory.saveToBytes(doc);
|
||||
}
|
||||
// Re-loading with no password proves security was stripped.
|
||||
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(decryptedSaved)) {
|
||||
assertThat(reloaded.isEncrypted()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile with positive small size uses byte[] path")
|
||||
void smallMultipartLoadsViaBytes() throws IOException {
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "s.pdf", MediaType.APPLICATION_PDF_VALUE, basePdfBytes);
|
||||
try (PDDocument doc = factory.load(multipart)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildEncryptedPdf(String ownerPw, String userPw) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
AccessPermission ap = new AccessPermission();
|
||||
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPw, userPw, ap);
|
||||
spp.setEncryptionKeyLength(128);
|
||||
doc.protect(spp);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
|
||||
import stirling.software.common.service.FileStorage.StoredFile;
|
||||
|
||||
class FileStorageMoreTest {
|
||||
|
||||
@TempDir Path storageDir;
|
||||
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileStorage =
|
||||
new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(storageDir.toString()),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeInputStream / getFileSize / retrieveInputStream")
|
||||
class StreamAndSize {
|
||||
|
||||
@Test
|
||||
@DisplayName("storeInputStream returns id and exact byte size")
|
||||
void storeInputStreamReturnsSize() throws IOException {
|
||||
byte[] payload = "twelve bytes".getBytes(StandardCharsets.UTF_8);
|
||||
StoredFile stored =
|
||||
fileStorage.storeInputStream(new ByteArrayInputStream(payload), "in.bin");
|
||||
assertThat(stored.fileId()).isNotBlank();
|
||||
assertThat(stored.size()).isEqualTo(payload.length);
|
||||
assertThat(fileStorage.getFileSize(stored.fileId())).isEqualTo(payload.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retrieveInputStream yields the stored content")
|
||||
void retrieveInputStreamContent() throws IOException {
|
||||
byte[] payload = "stream-me".getBytes(StandardCharsets.UTF_8);
|
||||
String id = fileStorage.storeBytes(payload, "s.bin");
|
||||
try (InputStream in = fileStorage.retrieveInputStream(id)) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeFile fast path")
|
||||
class FastPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("file-backed MultipartFile is stored via the Resource fast path")
|
||||
void fileBackedResourceStored(@TempDir Path src) throws IOException {
|
||||
byte[] payload = "file-backed-content".getBytes(StandardCharsets.UTF_8);
|
||||
Path onDisk = Files.write(src.resolve("upload.pdf"), payload);
|
||||
|
||||
// A MultipartFile whose getResource() reports isFile()=true exercises the
|
||||
// file-to-file copy branch in storeFile.
|
||||
MultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "upload.pdf", MediaType.APPLICATION_PDF_VALUE, payload) {
|
||||
@Override
|
||||
public org.springframework.core.io.Resource getResource() {
|
||||
return new FileSystemResource(onDisk);
|
||||
}
|
||||
};
|
||||
|
||||
String id = fileStorage.storeFile(multipart);
|
||||
assertThat(id).isNotBlank();
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-memory MultipartFile falls back to the stream copy path")
|
||||
void inMemoryFallback() throws IOException {
|
||||
byte[] payload = "memory-content".getBytes(StandardCharsets.UTF_8);
|
||||
MultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "m.pdf", MediaType.APPLICATION_PDF_VALUE, payload);
|
||||
String id = fileStorage.storeFile(multipart);
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeFromStreamingBody")
|
||||
class StreamingBody {
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path streams body to storage")
|
||||
void happyPath() throws IOException {
|
||||
byte[] payload = "streamed-body-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
StreamingResponseBody body = out -> out.write(payload);
|
||||
String id = fileStorage.storeFromStreamingBody(body, "body.bin");
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writer IOException propagates and leaves no lingering file")
|
||||
void writerErrorPropagatesAndCleansUp() throws IOException {
|
||||
long before = countFiles();
|
||||
StreamingResponseBody body =
|
||||
out -> {
|
||||
out.write("partial".getBytes(StandardCharsets.UTF_8));
|
||||
throw new IOException("boom mid-write");
|
||||
};
|
||||
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
assertThat(countFiles()).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unchecked writer failure is wrapped as IOException")
|
||||
void uncheckedWriterErrorWrapped() {
|
||||
StreamingResponseBody body =
|
||||
out -> {
|
||||
throw new IllegalStateException("unchecked boom");
|
||||
};
|
||||
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad2.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
private long countFiles() throws IOException {
|
||||
try (var s = Files.list(storageDir)) {
|
||||
return s.count();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/** Additional coverage for JobExecutorService branches not exercised by JobExecutorServiceTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobExecutorServiceMoreTest {
|
||||
|
||||
private JobExecutorService service;
|
||||
|
||||
@Mock private TaskManager taskManager;
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
@Mock private JobQueue jobQueue;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// request is null on purpose to exercise the request==null guard.
|
||||
service =
|
||||
new JobExecutorService(
|
||||
taskManager, fileStorage, null, resourceMonitor, jobQueue, 30000L, "30m");
|
||||
}
|
||||
|
||||
/** Concrete validation exception so we can drive the BaseValidationException rethrow branch. */
|
||||
private static class TestValidationException extends ExceptionUtils.BaseValidationException {
|
||||
TestValidationException(String message) {
|
||||
super(message, "E999");
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete app exception so we can drive the BaseAppException rethrow branch. */
|
||||
private static class TestAppException extends ExceptionUtils.BaseAppException {
|
||||
TestAppException(String message) {
|
||||
super(message, null, "E998");
|
||||
}
|
||||
}
|
||||
|
||||
/** Bean exposing getFileId/getOriginalFilename/getContentType for the reflection branch. */
|
||||
public static class FileIdBean {
|
||||
private final String fileId;
|
||||
private final String originalFilename;
|
||||
private final String contentType;
|
||||
|
||||
FileIdBean(String fileId, String originalFilename, String contentType) {
|
||||
this.fileId = fileId;
|
||||
this.originalFilename = originalFilename;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFileId() {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FileIdBean{fileId=" + fileId + "}";
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronous error mapping")
|
||||
class SyncErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("IllegalArgumentException is rethrown, not wrapped in a 500 body")
|
||||
void illegalArgumentRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new IllegalArgumentException("bad input");
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("bad input");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cause of BaseValidationException is rethrown")
|
||||
void validationCauseRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException(new TestValidationException("invalid"));
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(ExceptionUtils.BaseValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cause of BaseAppException is rethrown")
|
||||
void appCauseRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException(new TestAppException("app error"));
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(ExceptionUtils.BaseAppException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronous result handling")
|
||||
class SyncResults {
|
||||
|
||||
@Test
|
||||
@DisplayName("byte[] result becomes a PDF attachment response")
|
||||
void byteArrayBecomesAttachment() {
|
||||
byte[] payload = "pdf-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> payload);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo(payload);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("result.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile result is streamed back with its own content type")
|
||||
void multipartBecomesResponse() {
|
||||
MultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"f", "orig.txt", MediaType.TEXT_PLAIN_VALUE, "hi".getBytes());
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> file);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_PLAIN);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("orig.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ResponseEntity result is returned verbatim")
|
||||
void responseEntityReturnedVerbatim() {
|
||||
ResponseEntity<String> inner = ResponseEntity.status(HttpStatus.ACCEPTED).body("ok");
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> inner);
|
||||
assertThat(response).isSameAs(inner);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("asynchronous error handling")
|
||||
class AsyncErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("a thrown exception is recorded via TaskManager.setError")
|
||||
void asyncErrorRecorded() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException("async boom");
|
||||
};
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, work);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
verify(taskManager, timeout(5000)).setError(anyString(), eq("async boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job that exceeds its timeout is recorded as timed out")
|
||||
void asyncTimeoutRecorded() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond the 1ms timeout
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
// 1ms custom timeout, async, non-queueable.
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, work, 1L, false, 10);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
verify(taskManager, timeout(5000)).setError(anyString(), eq("Job timed out"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processJobResult branches (via async execution)")
|
||||
class ProcessJobResult {
|
||||
|
||||
@Test
|
||||
@DisplayName("raw byte[] result is stored and recorded as a file")
|
||||
void rawBytesStored() throws Exception {
|
||||
byte[] payload = "raw".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileStorage.storeBytes(any(byte[].class), eq("result.pdf")))
|
||||
.thenReturn("bytes-id");
|
||||
|
||||
service.runJobGeneric(true, () -> payload);
|
||||
|
||||
verify(fileStorage, timeout(5000)).storeBytes(any(byte[].class), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("bytes-id"),
|
||||
eq("result.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity<byte[]> is stored with the filename from headers")
|
||||
void responseEntityBytesStored() throws Exception {
|
||||
byte[] payload = "rebytes".getBytes(StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDisposition(
|
||||
ContentDisposition.formData().name("a").filename("out.pdf").build());
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(payload, headers, HttpStatus.OK);
|
||||
when(fileStorage.storeBytes(any(byte[].class), eq("out.pdf"))).thenReturn("re-id");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("re-id"),
|
||||
eq("out.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity<StreamingResponseBody> is stored via storeFromStreamingBody")
|
||||
void responseEntityStreamingStored() throws Exception {
|
||||
StreamingResponseBody body = out -> out.write("stream".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
when(fileStorage.storeFromStreamingBody(any(StreamingResponseBody.class), anyString()))
|
||||
.thenReturn("stream-id");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(fileStorage, timeout(5000))
|
||||
.storeFromStreamingBody(any(StreamingResponseBody.class), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("stream-id"), eq("result.pdf"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity body exposing getFileId is recorded via reflection")
|
||||
void responseEntityFileIdBean() {
|
||||
FileIdBean bean = new FileIdBean("bean-file", "bean.pdf", "text/custom");
|
||||
Supplier<Object> work = () -> ResponseEntity.ok(bean);
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("bean-file"), eq("bean.pdf"), eq("text/custom"));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain ResponseEntity body without fileId is stored as a generic result")
|
||||
void responseEntityPlainBody() {
|
||||
Supplier<Object> work = () -> ResponseEntity.ok("plain-string");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000)).setResult(anyString(), eq("plain-string"));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile result is stored via storeFile")
|
||||
void multipartStored() throws Exception {
|
||||
MultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"f", "m.pdf", MediaType.APPLICATION_PDF_VALUE, "m".getBytes());
|
||||
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("mp-id");
|
||||
|
||||
service.runJobGeneric(true, () -> file);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("mp-id"),
|
||||
eq("m.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain object result exposing getFileId is recorded via reflection")
|
||||
void plainObjectFileIdBean() {
|
||||
FileIdBean bean = new FileIdBean("plain-bean", "p.pdf", "app/p");
|
||||
|
||||
service.runJobGeneric(true, () -> bean);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("plain-bean"), eq("p.pdf"), eq("app/p"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a generic non-file object is stored via setResult")
|
||||
void genericObjectStored() {
|
||||
service.runJobGeneric(true, () -> "just-text");
|
||||
verify(taskManager, timeout(5000)).setResult(anyString(), eq("just-text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a storage failure is recorded as an error on the task")
|
||||
void storageFailureRecordsError() throws Exception {
|
||||
byte[] payload = "x".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileStorage.storeBytes(any(byte[].class), anyString()))
|
||||
.thenThrow(new java.io.IOException("disk full"));
|
||||
|
||||
service.runJobGeneric(true, () -> payload);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setError(anyString(), org.mockito.ArgumentMatchers.contains("disk full"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("queued execution")
|
||||
class QueuedExecution {
|
||||
|
||||
@Test
|
||||
@DisplayName("queued wrapped work stores its result through processJobResult on success")
|
||||
void queuedWorkSuccess() {
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
// Capture the wrapped supplier so we can run it as the queue would.
|
||||
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
|
||||
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
|
||||
.thenReturn(new CompletableFuture<>());
|
||||
|
||||
ResponseEntity<?> response =
|
||||
service.runJobGeneric(true, () -> "queued-ok", 5000, true, 80);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
|
||||
// Execute the wrapped work and assert it routed the result to TaskManager.
|
||||
Object result = workCaptor.getValue().get();
|
||||
assertThat(result).isEqualTo("queued-ok");
|
||||
verify(taskManager).setResult(anyString(), eq("queued-ok"));
|
||||
verify(taskManager).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("queued wrapped work records and rethrows on failure")
|
||||
void queuedWorkFailure() {
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
|
||||
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
|
||||
.thenReturn(new CompletableFuture<>());
|
||||
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new RuntimeException("queued-boom");
|
||||
};
|
||||
service.runJobGeneric(true, failing, 5000, true, 80);
|
||||
|
||||
assertThatThrownBy(() -> workCaptor.getValue().get())
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("queued-boom");
|
||||
verify(taskManager).setError(anyString(), eq("queued-boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job is not queued when it is synchronous even if queueable")
|
||||
void syncJobNeverQueued() {
|
||||
// queueable=true but async=false -> shouldQueue is false, runs inline.
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> "inline", 0, true, 90);
|
||||
assertThat(response.getBody()).isEqualTo("inline");
|
||||
verify(jobQueue, org.mockito.Mockito.never())
|
||||
.queueJob(anyString(), anyInt(), any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("job ownership scoping")
|
||||
class JobOwnership {
|
||||
|
||||
@Test
|
||||
@DisplayName("scoped job key and owner come from JobOwnershipService when present")
|
||||
void scopedKeyUsed() {
|
||||
JobOwnershipService ownership = org.mockito.Mockito.mock(JobOwnershipService.class);
|
||||
when(ownership.createScopedJobKey(anyString())).thenReturn("user1:scoped");
|
||||
lenient().when(ownership.getCurrentUserId()).thenReturn(Optional.of("user1"));
|
||||
ReflectionTestUtils.setField(service, "jobOwnershipService", ownership);
|
||||
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, () -> "owned");
|
||||
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
|
||||
assertThat(jobResponse.getJobId()).isEqualTo("user1:scoped");
|
||||
verify(taskManager).createTask("user1:scoped");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("session timeout parsing")
|
||||
class SessionTimeoutParsing {
|
||||
|
||||
private long parse(String value) {
|
||||
JobExecutorService s =
|
||||
new JobExecutorService(
|
||||
taskManager,
|
||||
fileStorage,
|
||||
null,
|
||||
resourceMonitor,
|
||||
jobQueue,
|
||||
999_999_999L,
|
||||
value);
|
||||
return (long) ReflectionTestUtils.getField(s, "effectiveTimeoutMs");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("seconds, hours and days units are parsed")
|
||||
void parsesUnits() {
|
||||
assertThat(parse("45s")).isEqualTo(45_000L);
|
||||
assertThat(parse("2h")).isEqualTo(2L * 60 * 60 * 1000);
|
||||
assertThat(parse("1d")).isEqualTo(24L * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unrecognised unit defaults to minutes")
|
||||
void unknownUnitDefaultsToMinutes() {
|
||||
assertThat(parse("5x")).isEqualTo(5L * 60 * 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/empty and unparseable values fall back to 30 minutes")
|
||||
void fallbackToThirtyMinutes() {
|
||||
long thirtyMin = 30L * 60 * 1000;
|
||||
assertThat(parse("")).isEqualTo(thirtyMin);
|
||||
assertThat(parse("garbage")).isEqualTo(thirtyMin);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sync timeout")
|
||||
class SyncTimeout {
|
||||
|
||||
@Test
|
||||
@DisplayName("a synchronous job that exceeds its timeout returns a 500 with a timeout body")
|
||||
void syncTimeoutReturns500() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond 1ms timeout
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, work, 1L);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> body = (Map<String, String>) response.getBody();
|
||||
assertThat(body.get("error")).contains("timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
/** Additional coverage for JobQueue branches not exercised by JobQueueTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobQueueMoreTest {
|
||||
|
||||
private JobQueue jobQueue;
|
||||
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
|
||||
private final AtomicReference<ResourceStatus> statusRef =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient()
|
||||
.when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt()))
|
||||
.thenReturn(10);
|
||||
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
|
||||
jobQueue = new JobQueue(resourceMonitor);
|
||||
}
|
||||
|
||||
private void invokeProcessQueue() {
|
||||
ReflectionTestUtils.invokeMethod(jobQueue, "processQueue");
|
||||
}
|
||||
|
||||
// Bounded wait: block up to 5s for the queued job's future to settle on the executor.
|
||||
private static void awaitDone(CompletableFuture<?> future) {
|
||||
try {
|
||||
future.handle((r, e) -> null).get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new AssertionError("future did not complete within 5s", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SmartLifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("start/stop toggles running and start is idempotent")
|
||||
void startStopToggle() {
|
||||
assertThat(jobQueue.isRunning()).isFalse();
|
||||
|
||||
jobQueue.start();
|
||||
assertThat(jobQueue.isRunning()).isTrue();
|
||||
|
||||
// Second start is a no-op (already running).
|
||||
jobQueue.start();
|
||||
assertThat(jobQueue.isRunning()).isTrue();
|
||||
|
||||
jobQueue.stop();
|
||||
assertThat(jobQueue.isRunning()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("phase and auto-startup expose lifecycle ordering")
|
||||
void phaseAndAutoStartup() {
|
||||
assertThat(jobQueue.getPhase()).isEqualTo(10);
|
||||
assertThat(jobQueue.isAutoStartup()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stop completes any still-pending futures exceptionally")
|
||||
void stopCompletesPendingFutures() {
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("pending", 50, () -> "x", 1000);
|
||||
assertThat(future.isDone()).isFalse();
|
||||
|
||||
// Drive shutdown without starting the scheduler so no processor races us to the job.
|
||||
jobQueue.stop();
|
||||
|
||||
assertThat(future).isCompletedExceptionally();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("queueJob capacity")
|
||||
class QueueCapacity {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a job when the queue is full")
|
||||
void rejectsWhenFull() {
|
||||
// Capacity-1 queue whose timed offer rejects instantly (no 5s block) when full.
|
||||
BlockingQueue<Object> instaReject =
|
||||
new LinkedBlockingQueue<>(1) {
|
||||
@Override
|
||||
public boolean offer(Object e, long timeout, TimeUnit unit) {
|
||||
return super.offer(e);
|
||||
}
|
||||
};
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", instaReject);
|
||||
jobQueue.queueJob("first", 50, () -> "a", 1000);
|
||||
|
||||
CompletableFuture<ResponseEntity<?>> rejected =
|
||||
jobQueue.queueJob("second", 50, () -> "b", 1000);
|
||||
|
||||
assertThat(rejected).isCompletedExceptionally();
|
||||
assertThat(jobQueue.getRejectedJobs()).isEqualTo(1);
|
||||
assertThat(jobQueue.isJobQueued("second")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getQueueCapacity reflects remaining capacity plus current size")
|
||||
void getQueueCapacityReports() {
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(5));
|
||||
jobQueue.queueJob("c1", 50, () -> "a", 1000);
|
||||
assertThat(jobQueue.getQueueCapacity()).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("job position")
|
||||
class JobPosition {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 for the first queued job and -1 for an unknown job")
|
||||
void positionAndUnknown() {
|
||||
jobQueue.queueJob("p1", 50, () -> "a", 1000);
|
||||
jobQueue.queueJob("p2", 50, () -> "b", 1000);
|
||||
|
||||
assertThat(jobQueue.getJobPosition("p1")).isEqualTo(0);
|
||||
assertThat(jobQueue.getJobPosition("p2")).isEqualTo(1);
|
||||
assertThat(jobQueue.getJobPosition("missing")).isEqualTo(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cancelJob")
|
||||
class CancelJob {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when the job id is unknown")
|
||||
void cancelUnknownReturnsFalse() {
|
||||
assertThat(jobQueue.cancelJob("nope")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processQueue")
|
||||
class ProcessQueue {
|
||||
|
||||
@Test
|
||||
@DisplayName("does nothing when shutting down")
|
||||
void noopWhenShuttingDown() {
|
||||
jobQueue.queueJob("s1", 50, () -> "a", 1000);
|
||||
ReflectionTestUtils.setField(jobQueue, "shuttingDown", true);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
// Still queued: the shutdown guard returned before polling.
|
||||
assertThat(jobQueue.isJobQueued("s1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("delays execution while the system is under critical load")
|
||||
void delaysUnderCriticalLoad() {
|
||||
statusRef.set(ResourceStatus.CRITICAL);
|
||||
jobQueue.queueJob("crit", 50, () -> "a", 1000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
// Critical load: job remains queued, nothing executed.
|
||||
assertThat(jobQueue.isJobQueued("crit")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("executes a queued job and completes its future when resources are OK")
|
||||
void executesWhenOk() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("ok", 50, () -> "done", 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(jobQueue.isJobQueued("ok")).isFalse();
|
||||
assertThat(future).isCompleted();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job past the max wait time still executes and adds a timeout note")
|
||||
void overdueJobExecutesAndNotes() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
ReflectionTestUtils.setField(jobQueue, "maxWaitTimeMs", 1L);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("overdue", 50, () -> "late-done", 5000);
|
||||
|
||||
// Backdate the queuedAt so wait-time exceeds maxWaitTimeMs.
|
||||
backdateQueuedAt("overdue");
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future).isCompleted();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void backdateQueuedAt(String jobId) {
|
||||
var jobMap =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(jobQueue, "jobMap");
|
||||
Object job = jobMap.get(jobId);
|
||||
ReflectionTestUtils.setField(job, "queuedAt", Instant.now().minusSeconds(60));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("executeJob")
|
||||
class ExecuteJob {
|
||||
|
||||
@Test
|
||||
@DisplayName("a cancelled job is skipped by executeJob without running its work")
|
||||
@SuppressWarnings("unchecked")
|
||||
void cancelledJobSkipped() throws Exception {
|
||||
java.util.concurrent.atomic.AtomicBoolean ran =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob(
|
||||
"cancelled",
|
||||
50,
|
||||
() -> {
|
||||
ran.set(true);
|
||||
return "should-not-run";
|
||||
},
|
||||
1000);
|
||||
|
||||
// Grab the real QueuedJob instance, mark it cancelled, then drive executeJob directly.
|
||||
var jobMap =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(jobQueue, "jobMap");
|
||||
Object job = jobMap.get("cancelled");
|
||||
ReflectionTestUtils.setField(job, "cancelled", true);
|
||||
|
||||
var executeJob = JobQueue.class.getDeclaredMethod("executeJob", job.getClass());
|
||||
executeJob.setAccessible(true);
|
||||
executeJob.invoke(jobQueue, job);
|
||||
|
||||
// The early return means the work supplier never ran.
|
||||
assertThat(ran.get()).isFalse();
|
||||
assertThat(future.isDone()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-ResponseEntity result is wrapped in ResponseEntity.ok")
|
||||
void nonResponseEntityWrapped() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("wrap", 50, () -> "plain", 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
ResponseEntity<?> response = future.join();
|
||||
assertThat(response.getBody()).isEqualTo("plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ResponseEntity result is forwarded as-is")
|
||||
void responseEntityForwarded() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
ResponseEntity<String> inner = ResponseEntity.ok("inner");
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("forward", 50, () -> inner, 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future.join()).isSameAs(inner);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a failing job completes its future exceptionally")
|
||||
void failingJobCompletesExceptionally() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new RuntimeException("exec-boom");
|
||||
};
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("fail", 50, failing, 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future).isCompletedExceptionally();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("executeWithTimeout")
|
||||
class ExecuteWithTimeout {
|
||||
|
||||
@Test
|
||||
@DisplayName("with no timeout it joins and returns the value")
|
||||
void noTimeoutJoins() {
|
||||
Object result =
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", (Supplier<Object>) () -> "joined", 0L);
|
||||
assertThat(result).isEqualTo("joined");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an execution failure is unwrapped to its cause")
|
||||
void executionFailureUnwrapped() {
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new IllegalStateException("inner-cause");
|
||||
};
|
||||
Throwable thrown =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Throwable.class,
|
||||
() ->
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", failing, 1000L));
|
||||
assertThat(messageChain(thrown)).contains("inner-cause");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a slow job exceeds the timeout and throws TimeoutException")
|
||||
void slowJobTimesOut() {
|
||||
Supplier<Object> slow =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond 1ms
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
Throwable thrown =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Throwable.class,
|
||||
() ->
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", slow, 1L));
|
||||
assertThat(messageChain(thrown)).contains("timed out");
|
||||
}
|
||||
|
||||
// Spring's ReflectionTestUtils wraps checked exceptions, so inspect the whole cause chain.
|
||||
private String messageChain(Throwable t) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Throwable c = t; c != null; c = c.getCause()) {
|
||||
if (c.getMessage() != null) {
|
||||
sb.append(c.getMessage()).append('|');
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateQueueCapacity")
|
||||
class UpdateQueueCapacity {
|
||||
|
||||
@Test
|
||||
@DisplayName("resizes the queue and preserves queued jobs when capacity changes")
|
||||
void resizesQueue() {
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(10));
|
||||
jobQueue.queueJob("keep", 50, () -> "a", 1000);
|
||||
|
||||
// Force a new, smaller capacity on the next recalculation.
|
||||
when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(4);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(jobQueue, "updateQueueCapacity");
|
||||
|
||||
assertThat(jobQueue.getQueueCapacity()).isEqualTo(4);
|
||||
// The previously queued job survived the drain into the new queue.
|
||||
assertThat(jobQueue.getCurrentQueueSize()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getQueueStats")
|
||||
class QueueStats {
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the current resource status name")
|
||||
void includesResourceStatus() {
|
||||
statusRef.set(ResourceStatus.WARNING);
|
||||
var stats = jobQueue.getQueueStats();
|
||||
assertThat(stats.get("resourceStatus")).isEqualTo("WARNING");
|
||||
assertThat(stats).containsKeys("queuedJobs", "queueCapacity", "rejectedJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
|
||||
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
|
||||
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
|
||||
* isolated.
|
||||
*/
|
||||
class LoginAgreementServiceTest {
|
||||
|
||||
@TempDir Path customFilesDir;
|
||||
|
||||
private ApplicationProperties properties;
|
||||
private ApplicationProperties.Legal.LoginAgreement config;
|
||||
private LoginAgreementService service;
|
||||
private Path disclaimerDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new ApplicationProperties();
|
||||
config = properties.getLegal().getLoginAgreement();
|
||||
service = new LoginAgreementService(properties);
|
||||
disclaimerDir = customFilesDir.resolve("disclaimer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
|
||||
*/
|
||||
private void withMockedPath(Runnable action) {
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getCustomFilesPath)
|
||||
.thenReturn(customFilesDir.toString());
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(String locale, String content) throws IOException {
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsReflectConfig() {
|
||||
config.setEnabled(true);
|
||||
config.setShowInAnonymousMode(false);
|
||||
assertTrue(service.isEnabled());
|
||||
assertFalse(service.isShowInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsExactLocaleFile() throws IOException {
|
||||
writeFile("fr-FR", "# Avis");
|
||||
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToBaseLanguage() throws IOException {
|
||||
// Only a language-only file exists; a region-specific request should fall back to it.
|
||||
writeFile("de", "# Hinweis");
|
||||
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToDefaultLocale() throws IOException {
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
writeFile("en-GB", "# Notice");
|
||||
// No file for the requested locale -> falls through to the configured default locale.
|
||||
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToFallbackTextWhenNoFile() {
|
||||
config.setFallbackText("# Fallback");
|
||||
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsEmptyWhenNothingConfigured() {
|
||||
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
|
||||
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
|
||||
Files.writeString(
|
||||
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
|
||||
config.setFallbackText("safe");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertEquals("safe", service.resolveContent("../secret"));
|
||||
assertEquals("safe", service.resolveContent("..%2Fsecret"));
|
||||
assertEquals("safe", service.resolveContent("/etc/passwd"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertNull(service.readRawForLocale("../secret"));
|
||||
assertNull(service.readRawForLocale("en/GB"));
|
||||
assertNull(service.readRawForLocale("C:\\x"));
|
||||
assertNull(service.readRawForLocale(null));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawReturnsEmptyForValidButAbsentLocale() {
|
||||
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlongLocaleIsRejectedWithoutStackOverflow() {
|
||||
// Guards against the regex-recursion stack overflow on unbounded input.
|
||||
String hostile = "en" + "-ab".repeat(4000);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
|
||||
assertNull(service.readRawForLocale(hostile));
|
||||
assertDoesNotThrow(() -> service.resolveContent(hostile));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeThenReadRoundTrips() throws IOException {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
|
||||
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBlankDeletesFile() throws IOException {
|
||||
writeFile("fr-FR", "# Bonjour");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
|
||||
assertEquals("", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() ->
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.writeForLocale("../escape", "x")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
|
||||
writeFile("en-GB", "a");
|
||||
writeFile("fr-FR", "b");
|
||||
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
var locales = service.listLocalesWithContent();
|
||||
assertTrue(locales.contains("en-GB"));
|
||||
assertTrue(locales.contains("fr-FR"));
|
||||
assertEquals(2, locales.size());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedFileIsIgnored() throws IOException {
|
||||
// Files beyond the read cap are skipped rather than loaded into heap.
|
||||
byte[] big = new byte[300 * 1024];
|
||||
java.util.Arrays.fill(big, (byte) 'x');
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.write(disclaimerDir.resolve("en-GB.md"), big);
|
||||
config.setFallbackText("small-fallback");
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.MemoryUsage;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
/** Additional coverage for ResourceMonitor branches not exercised by ResourceMonitorTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResourceMonitorMoreTest {
|
||||
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock private OperatingSystemMXBean osMXBean;
|
||||
@Mock private MemoryMXBean memoryMXBean;
|
||||
@Mock private MemoryUsage heapUsage;
|
||||
@Mock private MemoryUsage nonHeapUsage;
|
||||
|
||||
private final AtomicReference<ResourceStatus> currentStatus =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
private final AtomicReference<ResourceMetrics> latestMetrics =
|
||||
new AtomicReference<>(new ResourceMetrics());
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
resourceMonitor = new ResourceMonitor();
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
|
||||
}
|
||||
|
||||
private void stubMemory(long heapUsed, long nonHeapUsed) {
|
||||
lenient().when(heapUsage.getUsed()).thenReturn(heapUsed);
|
||||
lenient().when(nonHeapUsage.getUsed()).thenReturn(nonHeapUsed);
|
||||
lenient().when(memoryMXBean.getHeapMemoryUsage()).thenReturn(heapUsage);
|
||||
lenient().when(memoryMXBean.getNonHeapMemoryUsage()).thenReturn(nonHeapUsage);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateResourceMetrics status transitions")
|
||||
class UpdateMetrics {
|
||||
|
||||
@Test
|
||||
@DisplayName("high CPU load drives the status to CRITICAL")
|
||||
void criticalOnHighCpu() {
|
||||
// load average / processors = 4 / 2 = 2.0 -> well over critical threshold.
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(4.0);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.CRITICAL);
|
||||
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(2.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moderately high CPU load drives the status to WARNING")
|
||||
void warningOnModerateCpu() {
|
||||
// 1.6 / 2 = 0.8 -> above high (0.75) but below critical (0.9).
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(1.6);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("low load keeps the status at OK")
|
||||
void okOnLowLoad() {
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(0.2);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(4);
|
||||
stubMemory(1L, 1L);
|
||||
currentStatus.set(ResourceStatus.WARNING); // ensure a transition log path is hit
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a negative load average triggers the alternative CPU fallback")
|
||||
void negativeLoadUsesFallback() {
|
||||
// getSystemLoadAverage returns -1 on platforms (e.g. Windows) where it is unsupported.
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(-1.0);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(4);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
// The mock OS bean has no getProcessCpuLoad/getSystemCpuLoad, so fallback yields 0.5.
|
||||
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(0.5);
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exception while sampling is swallowed and status is unchanged")
|
||||
void samplingExceptionSwallowed() {
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(0.1);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
when(memoryMXBean.getHeapMemoryUsage())
|
||||
.thenThrow(new RuntimeException("jmx unavailable"));
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
|
||||
// Must not propagate; the catch in updateResourceMetrics handles it.
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAlternativeCpuLoad")
|
||||
class AlternativeCpuLoad {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses getProcessCpuLoad via reflection when present")
|
||||
void usesProcessCpuLoad() {
|
||||
// A bean exposing getProcessCpuLoad lets the reflective fallback return its value.
|
||||
OperatingSystemMXBean withCpuLoad = new OsBeanWithProcessCpuLoad(0.42);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", withCpuLoad);
|
||||
|
||||
double load =
|
||||
(double)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
resourceMonitor, "getAlternativeCpuLoad");
|
||||
assertThat(load).isEqualTo(0.42);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults to 0.5 when no CPU-load method is available")
|
||||
void defaultsWhenUnavailable() {
|
||||
double load =
|
||||
(double)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
resourceMonitor, "getAlternativeCpuLoad");
|
||||
assertThat(load).isEqualTo(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateDynamicQueueCapacity memory pressure")
|
||||
class MemoryPressure {
|
||||
|
||||
@Test
|
||||
@DisplayName("high memory usage halves the computed capacity")
|
||||
void highMemoryHalvesCapacity() {
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
// memoryUsage > 0.8 triggers the additional 0.5 multiplier.
|
||||
latestMetrics.set(new ResourceMetrics(0.1, 0.85, 1, 1, 1, Instant.now()));
|
||||
|
||||
int capacity = resourceMonitor.calculateDynamicQueueCapacity(10, 2);
|
||||
// OK factor 1.0 * 0.5 = 0.5; ceil(10 * 0.5) = 5.
|
||||
assertThat(capacity).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ResourceMetrics")
|
||||
class Metrics {
|
||||
|
||||
@Test
|
||||
@DisplayName("getAge returns a non-negative duration")
|
||||
void getAgeNonNegative() {
|
||||
ResourceMetrics m = new ResourceMetrics(0, 0, 0, 0, 0, Instant.now().minusSeconds(1));
|
||||
assertThat(m.getAge().toMillis()).isGreaterThanOrEqualTo(1000L);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("lifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize schedules sampling and shutdown stops the scheduler")
|
||||
void initializeAndShutdown() {
|
||||
// Real bean so initialize() schedules against a live virtual-thread scheduler.
|
||||
ResourceMonitor live = new ResourceMonitor();
|
||||
ReflectionTestUtils.setField(live, "monitorIntervalMs", 60000L);
|
||||
live.initialize();
|
||||
|
||||
ScheduledExecutorService scheduler =
|
||||
(ScheduledExecutorService) ReflectionTestUtils.getField(live, "scheduler");
|
||||
assertThat(scheduler.isShutdown()).isFalse();
|
||||
|
||||
live.shutdown();
|
||||
assertThat(scheduler.isShutdown()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal OS bean stub exposing getProcessCpuLoad so the reflective fallback can find it. */
|
||||
private static class OsBeanWithProcessCpuLoad implements OperatingSystemMXBean {
|
||||
private final double cpuLoad;
|
||||
|
||||
OsBeanWithProcessCpuLoad(double cpuLoad) {
|
||||
this.cpuLoad = cpuLoad;
|
||||
}
|
||||
|
||||
// Reflectively located by getAlternativeCpuLoad.
|
||||
public double getProcessCpuLoad() {
|
||||
return cpuLoad;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getArch() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVersion() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAvailableProcessors() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getSystemLoadAverage() {
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public javax.management.ObjectName getObjectName() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Html.UrlSecurity;
|
||||
import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel;
|
||||
|
||||
class SsrfProtectionServiceTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private UrlSecurity config;
|
||||
private SsrfProtectionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
// Real config object: drill down to the live UrlSecurity instance and mutate it.
|
||||
config = applicationProperties.getSystem().getHtml().getUrlSecurity();
|
||||
service = new SsrfProtectionService(applicationProperties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Protection disabled / always-allowed inputs")
|
||||
class AlwaysAllowed {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true for any URL when protection disabled")
|
||||
void disabledAllowsEverything() {
|
||||
config.setEnabled(false);
|
||||
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("not a url")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\t"})
|
||||
@DisplayName("returns false for null/blank when enabled")
|
||||
void blankRejected(String url) {
|
||||
config.setEnabled(true);
|
||||
assertThat(service.isUrlAllowed(url)).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"data:text/plain;base64,SGVsbG8=",
|
||||
"DATA:image/png;base64,iVBOR",
|
||||
"#section",
|
||||
"#"
|
||||
})
|
||||
@DisplayName("data: URLs and fragments are always allowed")
|
||||
void dataAndFragmentAllowed(String url) {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MAX);
|
||||
assertThat(service.isUrlAllowed(url)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("OFF level")
|
||||
class OffLevel {
|
||||
|
||||
@Test
|
||||
@DisplayName("allows external and internal hosts alike")
|
||||
void offAllowsAll() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.OFF);
|
||||
assertThat(service.isUrlAllowed("http://10.0.0.1/secret")).isTrue();
|
||||
assertThat(service.isUrlAllowed("https://example.com")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MAX level - allowlist only")
|
||||
class MaxLevel {
|
||||
|
||||
@BeforeEach
|
||||
void max() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MAX);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows only whitelisted hosts (case-insensitive)")
|
||||
void allowsWhitelistedHost() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("https://EXAMPLE.com/path")).isTrue();
|
||||
assertThat(service.isUrlAllowed("https://other.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks when allowlist is empty")
|
||||
void emptyAllowlistBlocks() {
|
||||
assertThat(service.isUrlAllowed("https://example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks URL with no host")
|
||||
void noHostBlocked() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("file:///etc/passwd")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks malformed URL (parse exception path)")
|
||||
void malformedBlocked() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("http://exa mple.com")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - host parsing and lists")
|
||||
class MediumHostAndLists {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows a normal public literal IP")
|
||||
void allowsPublicIp() {
|
||||
assertThat(service.isUrlAllowed("http://93.184.216.34/page")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks URL with no host")
|
||||
void noHostBlocked() {
|
||||
assertThat(service.isUrlAllowed("mailto:test@example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks malformed URL (parse exception path)")
|
||||
void malformedBlocked() {
|
||||
assertThat(service.isUrlAllowed("ht!tp://%%%")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks explicitly blocked domain (case-insensitive)")
|
||||
void blockedDomain() {
|
||||
config.setBlockedDomains(List.of("evil.com"));
|
||||
assertThat(service.isUrlAllowed("http://EVIL.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks internal TLD suffixes")
|
||||
void internalTld() {
|
||||
// default internalTlds include .local, .internal, .corp, .home
|
||||
assertThat(service.isUrlAllowed("http://server.local")).isFalse();
|
||||
assertThat(service.isUrlAllowed("http://host.internal")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allowlist present: host not in list is blocked before any DNS lookup")
|
||||
void allowlistRejectsUnlisted() {
|
||||
// notexample.com is rejected by the allowlist check, which runs before DNS resolution,
|
||||
// so this stays deterministic offline.
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("http://notexample.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allowlist present: exact host and subdomain pass the allowlist gate")
|
||||
void allowlistAcceptsExactAndSubdomain() {
|
||||
// Allow a literal IP so the subsequent DNS resolution is the identity and network
|
||||
// checks are disabled, keeping the allow path deterministic without external DNS.
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLocalhost(false);
|
||||
config.setBlockLinkLocal(false);
|
||||
config.setBlockCloudMetadata(false);
|
||||
config.setAllowedDomains(List.of("93.184.216.34"));
|
||||
assertThat(service.isUrlAllowed("http://93.184.216.34")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - network based blocking via literal IPs")
|
||||
class MediumNetworkBlocking {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks loopback when blockLocalhost enabled")
|
||||
void blocksLoopback() {
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1/admin")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows loopback when blockLocalhost disabled and private/link checks off")
|
||||
void allowsLoopbackWhenAllChecksOff() {
|
||||
config.setBlockLocalhost(false);
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLinkLocal(false);
|
||||
config.setBlockCloudMetadata(false);
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1/ok")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"http://10.1.2.3",
|
||||
"http://192.168.0.5",
|
||||
"http://172.16.0.9",
|
||||
"http://172.31.255.1",
|
||||
"http://100.64.0.1"
|
||||
})
|
||||
@DisplayName("blocks RFC1918 / CGNAT private ranges")
|
||||
void blocksPrivateRanges(String url) {
|
||||
assertThat(service.isUrlAllowed(url)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("172.x and 100.x outside private sub-range are not private")
|
||||
void boundaryRangesNotPrivate() {
|
||||
// 172.15/172.32 outside 16-31; 100.63/100.128 outside 64-127.
|
||||
assertThat(service.isUrlAllowed("http://172.15.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://172.32.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://100.63.0.1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows private range when blockPrivateNetworks disabled")
|
||||
void allowsPrivateWhenDisabled() {
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLocalhost(false);
|
||||
assertThat(service.isUrlAllowed("http://10.1.2.3")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks link-local 169.254.x via private-network check")
|
||||
void blocksLinkLocal() {
|
||||
assertThat(service.isUrlAllowed("http://169.254.1.1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks AWS cloud-metadata IP 169.254.169.254")
|
||||
void blocksCloudMetadata() {
|
||||
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data/")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks unspecified address 0.0.0.0")
|
||||
void blocksUnspecified() {
|
||||
assertThat(service.isUrlAllowed("http://0.0.0.0")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks unresolvable host (UnknownHostException path)")
|
||||
void blocksUnresolvableHost() {
|
||||
assertThat(service.isUrlAllowed("http://nonexistent-host-stirling-test.invalid/page"))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - IPv6 literal handling")
|
||||
class MediumIpv6 {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 loopback ::1")
|
||||
void blocksIpv6Loopback() {
|
||||
assertThat(service.isUrlAllowed("http://[::1]/path")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 unique-local fc00::/7")
|
||||
void blocksIpv6UniqueLocal() {
|
||||
assertThat(service.isUrlAllowed("http://[fc00::1]")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 link-local fe80::/10")
|
||||
void blocksIpv6LinkLocal() {
|
||||
assertThat(service.isUrlAllowed("http://[fe80::1]")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv4-mapped IPv6 of a private address")
|
||||
void blocksIpv4MappedPrivate() {
|
||||
assertThat(service.isUrlAllowed("http://[::ffff:10.0.0.1]")).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/** Additional coverage for TaskManager branches not exercised by TaskManagerTest. */
|
||||
class TaskManagerMoreTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobStore jobStore;
|
||||
@Mock private ClusterBackplane clusterBackplane;
|
||||
|
||||
@InjectMocks private TaskManager taskManager;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
|
||||
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
private static byte[] buildZip(String... entryNames) throws Exception {
|
||||
var baos = new java.io.ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (String name : entryNames) {
|
||||
zos.putNextEntry(new ZipEntry(name));
|
||||
zos.write(("content-of-" + name).getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setFileResult ZIP handling")
|
||||
class ZipHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts a ZIP into individual file results and deletes the original")
|
||||
void extractsZipIntoIndividualFiles() throws Exception {
|
||||
String jobId = "zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
byte[] zipBytes = buildZip("a.pdf", "b.txt");
|
||||
when(fileStorage.retrieveInputStream("zip-file-id"))
|
||||
.thenReturn(new ByteArrayInputStream(zipBytes));
|
||||
// Each extracted entry is stored, returning a distinct StoredFile.
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenReturn(new FileStorage.StoredFile("extracted-a", 11L))
|
||||
.thenReturn(new FileStorage.StoredFile("extracted-b", 22L));
|
||||
when(fileStorage.deleteFile("zip-file-id")).thenReturn(true);
|
||||
|
||||
taskManager.setFileResult(jobId, "zip-file-id", "bundle.zip", "application/zip");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.isComplete()).isTrue();
|
||||
assertThat(result.hasMultipleFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles()).hasSize(2);
|
||||
// Content type is derived from the entry extension, not the ZIP content type.
|
||||
assertThat(result.getAllResultFiles().get(0).getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_PDF_VALUE);
|
||||
assertThat(result.getAllResultFiles().get(1).getContentType())
|
||||
.isEqualTo(MediaType.TEXT_PLAIN_VALUE);
|
||||
verify(fileStorage).deleteFile("zip-file-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty ZIP falls back to a single-file result")
|
||||
void emptyZipFallsBackToSingleFile() throws Exception {
|
||||
String jobId = "empty-zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
byte[] emptyZip = buildZip();
|
||||
when(fileStorage.retrieveInputStream("empty-zip-id"))
|
||||
.thenReturn(new ByteArrayInputStream(emptyZip));
|
||||
when(fileStorage.getFileSize("empty-zip-id")).thenReturn(7L);
|
||||
|
||||
taskManager.setFileResult(jobId, "empty-zip-id", "empty.zip", "application/zip");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasMultipleFiles()).isFalse();
|
||||
assertThat(result.getAllResultFiles()).hasSize(1);
|
||||
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("empty-zip-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZIP extraction failure falls back to a single-file result")
|
||||
void zipExtractionFailureFallsBackToSingleFile() throws Exception {
|
||||
String jobId = "bad-zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// retrieveInputStream throws so extractZipToIndividualFiles fails and we fall back.
|
||||
when(fileStorage.retrieveInputStream("bad-zip-id"))
|
||||
.thenThrow(new java.io.IOException("boom"));
|
||||
when(fileStorage.getFileSize("bad-zip-id")).thenReturn(99L);
|
||||
|
||||
taskManager.setFileResult(
|
||||
jobId, "bad-zip-id", "broken.zip", "application/x-zip-compressed");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("bad-zip-id");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setFileResult size fallback")
|
||||
class SizeFallback {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses size 0 when getFileSize throws for a non-zip file")
|
||||
void usesZeroSizeWhenGetFileSizeThrows() throws Exception {
|
||||
String jobId = "size-fail-job";
|
||||
taskManager.createTask(jobId);
|
||||
when(fileStorage.getFileSize("file-x")).thenThrow(new java.io.IOException("no stat"));
|
||||
|
||||
taskManager.setFileResult(jobId, "file-x", "doc.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.getAllResultFiles().get(0).getFileSize()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMultipleFileResults")
|
||||
class MultipleFileResults {
|
||||
|
||||
@Test
|
||||
@DisplayName("stores the provided list directly")
|
||||
void storesProvidedList() {
|
||||
String jobId = "multi-job";
|
||||
taskManager.createTask(jobId);
|
||||
List<ResultFile> files =
|
||||
List.of(
|
||||
ResultFile.builder().fileId("f1").fileName("1.pdf").build(),
|
||||
ResultFile.builder().fileId("f2").fileName("2.pdf").build());
|
||||
|
||||
taskManager.setMultipleFileResults(jobId, files);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasMultipleFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles()).hasSize(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getJobStats edge cases")
|
||||
class StatsEdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty manager reports zero average processing time")
|
||||
void emptyManagerZeroAverage() {
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
assertThat(stats.getTotalJobs()).isZero();
|
||||
assertThat(stats.getAverageProcessingTimeMs()).isZero();
|
||||
assertThat(stats.getOldestActiveJobTime()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accumulates processing time across multiple completed jobs")
|
||||
void accumulatesProcessingTime() {
|
||||
taskManager.createTask("c1");
|
||||
taskManager.setResult("c1", "r1");
|
||||
taskManager.createTask("c2");
|
||||
taskManager.setResult("c2", "r2");
|
||||
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
assertThat(stats.getCompletedJobs()).isEqualTo(2);
|
||||
assertThat(stats.getSuccessfulJobs()).isEqualTo(2);
|
||||
assertThat(stats.getAverageProcessingTimeMs()).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findResultFileByFileId")
|
||||
class FindResultFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns matching ResultFile metadata")
|
||||
void returnsMatch() throws Exception {
|
||||
taskManager.createTask("rf-job");
|
||||
when(fileStorage.getFileSize("target")).thenReturn(5L);
|
||||
taskManager.setFileResult("rf-job", "target", "t.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
ResultFile found = taskManager.findResultFileByFileId("target");
|
||||
assertThat(found).isNotNull();
|
||||
assertThat(found.getFileId()).isEqualTo("target");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when no job owns the file id")
|
||||
void returnsNullWhenAbsent() {
|
||||
assertThat(taskManager.findResultFileByFileId("nope")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findJobKeyByFileId")
|
||||
class FindJobKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the local job key when a job owns the file id")
|
||||
void returnsLocalKey() throws Exception {
|
||||
taskManager.createTask("owner-job");
|
||||
when(fileStorage.getFileSize("owned")).thenReturn(3L);
|
||||
taskManager.setFileResult(
|
||||
"owner-job", "owned", "o.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
assertThat(taskManager.findJobKeyByFileId("owned")).isEqualTo("owner-job");
|
||||
// Local hit must not consult the JobStore.
|
||||
verify(jobStore, never()).findJobIdByFileId(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when JobStore also has no match")
|
||||
void returnsNullWhenJobStoreEmpty() {
|
||||
when(jobStore.findJobIdByFileId("ghost")).thenReturn(Optional.empty());
|
||||
assertThat(taskManager.findJobKeyByFileId("ghost")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates JobStore lookup failures instead of returning null")
|
||||
void propagatesJobStoreFailure() {
|
||||
when(jobStore.findJobIdByFileId("blip"))
|
||||
.thenThrow(new RuntimeException("backplane down"));
|
||||
assertThatThrownBy(() -> taskManager.findJobKeyByFileId("blip"))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("backplane down");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupOldJobs resilience")
|
||||
class CleanupResilience {
|
||||
|
||||
@Test
|
||||
@DisplayName("continues when a file deletion throws during cleanup")
|
||||
void continuesWhenDeleteThrows() throws Exception {
|
||||
String jobId = "old-file-job";
|
||||
taskManager.createTask(jobId);
|
||||
JobResult job = taskManager.getJobResult(jobId);
|
||||
ResultFile rf =
|
||||
ResultFile.builder()
|
||||
.fileId("doomed")
|
||||
.fileName("d.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF_VALUE)
|
||||
.fileSize(1L)
|
||||
.build();
|
||||
ReflectionTestUtils.setField(job, "resultFiles", List.of(rf));
|
||||
ReflectionTestUtils.setField(job, "complete", true);
|
||||
ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2));
|
||||
|
||||
when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked"));
|
||||
|
||||
// Must not propagate; the job is still removed afterwards.
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, JobResult> map =
|
||||
(Map<String, JobResult>)
|
||||
ReflectionTestUtils.getField(taskManager, "jobResults");
|
||||
assertThat(map).doesNotContainKey(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("write-through failures")
|
||||
class WriteThroughFailures {
|
||||
|
||||
@Test
|
||||
@DisplayName("a JobStore put failure does not break createTask")
|
||||
void putFailureSwallowed() {
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("store offline"))
|
||||
.when(jobStore)
|
||||
.put(any(), any());
|
||||
// createTask -> writeThrough; the RuntimeException is caught and logged.
|
||||
taskManager.createTask("wt-job");
|
||||
assertThat(taskManager.getJobResult("wt-job")).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("toEntry mapping")
|
||||
class ToEntryMapping {
|
||||
|
||||
@Test
|
||||
@DisplayName("a failed job maps to FAILED state in the JobStore entry")
|
||||
void failedJobMapsToFailedState() {
|
||||
taskManager.createTask("fail-job");
|
||||
taskManager.setError("fail-job", "kaboom");
|
||||
|
||||
var captor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.common.cluster.JobStoreEntry.class);
|
||||
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
|
||||
assertThat(captor.getValue().jobId()).isEqualTo("fail-job");
|
||||
assertThat(captor.getAllValues())
|
||||
.anySatisfy(
|
||||
e ->
|
||||
assertThat(e.state())
|
||||
.isEqualTo(
|
||||
stirling.software.common.cluster.JobStoreEntry
|
||||
.JobState.FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addNote write-through")
|
||||
class AddNoteWriteThrough {
|
||||
|
||||
@Test
|
||||
@DisplayName("note is reflected in JobStore entry metadata")
|
||||
void noteWritesMetadata() {
|
||||
taskManager.createTask("note-job");
|
||||
assertThat(taskManager.addNote("note-job", "hello")).isTrue();
|
||||
|
||||
var captor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.common.cluster.JobStoreEntry.class);
|
||||
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
|
||||
assertThat(captor.getAllValues())
|
||||
.anySatisfy(e -> assertThat(e.resultMeta()).containsKey("notesCount"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/** Additional coverage for TempFileCleanupService branches not exercised by the base test. */
|
||||
class TempFileCleanupServiceMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private TempFileRegistry registry;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
@Mock private ApplicationProperties.TempFileManagement tempFileManagement;
|
||||
|
||||
@InjectMocks private TempFileCleanupService cleanupService;
|
||||
|
||||
private Path systemTempDir;
|
||||
private Path customTempDir;
|
||||
private Path libreOfficeTempDir;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
|
||||
systemTempDir = tempDir.resolve("systemTemp");
|
||||
customTempDir = tempDir.resolve("customTemp");
|
||||
libreOfficeTempDir = tempDir.resolve("libreOfficeTemp");
|
||||
Files.createDirectories(systemTempDir);
|
||||
Files.createDirectories(customTempDir);
|
||||
Files.createDirectories(libreOfficeTempDir);
|
||||
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(system);
|
||||
lenient().when(system.getTempFileManagement()).thenReturn(tempFileManagement);
|
||||
lenient().when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
|
||||
lenient()
|
||||
.when(tempFileManagement.getLibreofficeDir())
|
||||
.thenReturn(libreOfficeTempDir.toString());
|
||||
lenient().when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
lenient().when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
lenient().when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
|
||||
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
lenient().when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
private static void backdate(Path file, long millisAgo) throws IOException {
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.fromMillis(System.currentTimeMillis() - millisAgo));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isContainerMode")
|
||||
class ContainerMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("Docker and Kubernetes are container modes; others are not")
|
||||
void detectsContainerMachineTypes() {
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Docker");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isTrue();
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Kubernetes");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isTrue();
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSystemTempPath")
|
||||
class SystemTempPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the configured system temp dir when set")
|
||||
void usesConfiguredDir() {
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
Path path =
|
||||
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
|
||||
assertThat(path).isEqualTo(systemTempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to java.io.tmpdir when unset")
|
||||
void fallsBackToJavaTmpDir() {
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn("");
|
||||
Path path =
|
||||
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
|
||||
assertThat(path).isEqualTo(Path.of(System.getProperty("java.io.tmpdir")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("init")
|
||||
class Init {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates configured temp directories that do not yet exist")
|
||||
void createsMissingDirectories() {
|
||||
Path newBase = tempDir.resolve("newBase");
|
||||
Path newLo = tempDir.resolve("newLo");
|
||||
when(tempFileManagement.getBaseTmpDir()).thenReturn(newBase.toString());
|
||||
when(tempFileManagement.getLibreofficeDir()).thenReturn(newLo.toString());
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
|
||||
cleanupService.init();
|
||||
|
||||
assertThat(Files.exists(newBase)).isTrue();
|
||||
assertThat(Files.exists(newLo)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runs startup cleanup when enabled")
|
||||
void runsStartupCleanupWhenEnabled() throws IOException {
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(true);
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// An old stirling temp file in the custom dir should be removed by startup cleanup.
|
||||
Path stale = Files.createFile(customTempDir.resolve("stirling-pdf-stale.tmp"));
|
||||
backdate(stale, 48L * 60 * 60 * 1000); // 48h old, beyond non-container 24h cutoff
|
||||
|
||||
cleanupService.init();
|
||||
|
||||
assertThat(Files.exists(stale)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("scheduledCleanup")
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
// The registered directory was removed by GeneralUtils.deleteDirectory.
|
||||
assertThat(Files.exists(regDir)).isFalse();
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(tempDir.resolve("ghostDir"));
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// No exception even though the directory does not exist.
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
verify(registry).getTempDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupUnregisteredFiles system-temp inclusion")
|
||||
class CleanupUnregistered {
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the system temp dir when cleanupSystemTemp is enabled")
|
||||
void includesSystemTempDir() throws Exception {
|
||||
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(true);
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// Old stirling file in the system temp dir should be deleted in container mode.
|
||||
Path stale = Files.createFile(systemTempDir.resolve("stirling-pdf-sys.tmp"));
|
||||
backdate(stale, 2L * 60 * 60 * 1000); // 2h old
|
||||
|
||||
int deleted =
|
||||
(int)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService,
|
||||
"cleanupUnregisteredFiles",
|
||||
true,
|
||||
true,
|
||||
3600000L);
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||
assertThat(Files.exists(stale)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("registered-file skip and recursion depth")
|
||||
class RegistryAndDepth {
|
||||
|
||||
@Test
|
||||
@DisplayName("a registered file is never deleted")
|
||||
void registeredFilePreserved() throws Exception {
|
||||
Path registered = Files.createFile(systemTempDir.resolve("output_registered.pdf"));
|
||||
backdate(registered, 2L * 60 * 60 * 1000);
|
||||
// The registry reports the file as registered, so cleanup must skip it.
|
||||
when(registry.contains(any(File.class))).thenReturn(true);
|
||||
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, 0, false, 3600000L);
|
||||
|
||||
assertThat(Files.exists(registered)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recursion stops once the maximum depth is exceeded")
|
||||
void recursionDepthGuard() throws Exception {
|
||||
// Starting beyond MAX_RECURSION_DEPTH (5) returns immediately without listing.
|
||||
Path deepFile = Files.createFile(systemTempDir.resolve("output_deep.pdf"));
|
||||
backdate(deepFile, 2L * 60 * 60 * 1000);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, 6, false, 3600000L);
|
||||
|
||||
// Depth guard hit: the file was not visited or deleted.
|
||||
assertThat(Files.exists(deepFile)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupLibreOfficeTempFiles")
|
||||
class LibreOfficeCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("clears contents of registered libreoffice directories but keeps the dir")
|
||||
void clearsLibreOfficeContents() throws IOException {
|
||||
Path loDir = Files.createDirectories(tempDir.resolve("libreoffice-conv"));
|
||||
Path inside = Files.createFile(loDir.resolve("output_lo.pdf"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(loDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
cleanupService.cleanupLibreOfficeTempFiles();
|
||||
|
||||
// The file is removed (age ignored), directory itself remains.
|
||||
assertThat(Files.exists(inside)).isFalse();
|
||||
assertThat(Files.exists(loDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ignores registered directories that are not libreoffice dirs")
|
||||
void ignoresNonLibreOfficeDirs() throws IOException {
|
||||
Path other = Files.createDirectories(tempDir.resolve("other-dir"));
|
||||
Path keep = Files.createFile(other.resolve("output_keep.pdf"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(other);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
|
||||
cleanupService.cleanupLibreOfficeTempFiles();
|
||||
|
||||
// Not a libreoffice dir, so its contents are untouched.
|
||||
assertThat(Files.exists(keep)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupPDFBoxCache")
|
||||
class PdfBoxCache {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes an existing .pdfbox.cache file in the user home")
|
||||
void deletesCacheFile() throws IOException {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("home"));
|
||||
Path cache = Files.createFile(fakeHome.resolve(".pdfbox.cache"));
|
||||
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
|
||||
assertThat(Files.exists(cache)).isFalse();
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("is a no-op when no cache file exists")
|
||||
void noOpWhenNoCache() throws IOException {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("home2"));
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
// No exception when the cache file is absent.
|
||||
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
|
||||
assertThat(Files.exists(fakeHome.resolve(".pdfbox.cache"))).isFalse();
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Point user.home at a throwaway dir so the real ~/.pdfbox.cache is never touched.
|
||||
private void withIsolatedUserHome(Runnable action) {
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("isolated-home"));
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
action.run();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
|
||||
private void invokeCleanupDirectoryStreaming(
|
||||
Path directory, int depth, boolean containerMode, long maxAgeMillis) {
|
||||
try {
|
||||
Consumer<Path> noop = p -> {};
|
||||
var method =
|
||||
TempFileCleanupService.class.getDeclaredMethod(
|
||||
"cleanupDirectoryStreaming",
|
||||
Path.class,
|
||||
boolean.class,
|
||||
int.class,
|
||||
long.class,
|
||||
boolean.class,
|
||||
Consumer.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(
|
||||
cleanupService, directory, containerMode, depth, maxAgeMillis, false, noop);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CbrUtils#convertCbrToPdf}. junrar cannot parse synthetic RAR data,
|
||||
* so these exercise the archive-open failure branches (corrupt header / invalid format) by feeding
|
||||
* non-RAR bytes through a real {@link CustomPDFDocumentFactory} and {@link TempFileManager}. No
|
||||
* external tool is launched.
|
||||
*/
|
||||
class CbrUtilsMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomPDFDocumentFactory factory;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-cbr-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
}
|
||||
|
||||
private static MultipartFile cbr(String filename, byte[] bytes) {
|
||||
return new MockMultipartFile("file", filename, "application/x-cbr", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf - invalid archives")
|
||||
class InvalidArchiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-RAR bytes in a .cbr file are rejected as an invalid archive")
|
||||
void nonRarContentCbr() {
|
||||
byte[] junk = "this is not a rar archive at all".getBytes(StandardCharsets.UTF_8);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("comic.cbr", junk), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-RAR bytes in a .rar file are rejected as an invalid archive")
|
||||
void nonRarContentRar() {
|
||||
byte[] junk = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("archive.rar", junk), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bytes carrying the RAR signature but no valid body are rejected")
|
||||
void rarSignatureOnly() {
|
||||
// "Rar!\x1A\x07\x00" is the classic RAR4 signature; body is missing/garbage.
|
||||
byte[] data = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("comic.cbr", data), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf - validation overload")
|
||||
class ValidationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the 3-arg overload delegates and still validates the extension")
|
||||
void threeArgOverloadValidatesExtension() {
|
||||
MultipartFile wrong = cbr("document.pdf", "x".getBytes(StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(wrong, factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty .cbr file is rejected before archive parsing")
|
||||
void emptyFile() {
|
||||
MultipartFile empty = cbr("comic.cbr", new byte[0]);
|
||||
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(empty, factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isCbrFile additional branches")
|
||||
class IsCbrFileTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a .zip file is not a CBR")
|
||||
void zipIsNotCbr() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
org.mockito.Mockito.when(file.getOriginalFilename()).thenReturn("bundle.zip");
|
||||
assertThat(CbrUtils.isCbrFile(file)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CbzUtils#convertCbzToPdf} that build real in-memory CBZ (ZIP)
|
||||
* archives containing real PNG images and convert them with a real {@link
|
||||
* CustomPDFDocumentFactory}. No external process is launched (optimizeForEbook is left off so
|
||||
* Ghostscript is never invoked).
|
||||
*/
|
||||
class CbzUtilsMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomPDFDocumentFactory factory;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-cbz-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
}
|
||||
|
||||
private static byte[] pngBytes(Color color) throws IOException {
|
||||
BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "PNG", baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Build a CBZ (ZIP) from name->bytes entries. */
|
||||
private static byte[] buildCbz(String[] names, byte[][] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
if (contents[i] != null) {
|
||||
zos.write(contents[i]);
|
||||
}
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static MultipartFile cbz(byte[] bytes) {
|
||||
return new MockMultipartFile("file", "comic.cbz", "application/x-cbz", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf - happy path")
|
||||
class HappyPathTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a CBZ with two images converts to a two-page PDF, sorted by natural order")
|
||||
void twoImagesToPdf() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"page2.png", "page10.png", "page1.png"},
|
||||
new byte[][] {
|
||||
pngBytes(Color.RED), pngBytes(Color.GREEN), pngBytes(Color.BLUE)
|
||||
});
|
||||
|
||||
try (TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
|
||||
assertThat(resultPdf.exists()).isTrue();
|
||||
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-image entries are ignored, only images become pages")
|
||||
void mixedEntries() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"readme.txt", "cover.png"},
|
||||
new byte[][] {
|
||||
"notes".getBytes(StandardCharsets.UTF_8), pngBytes(Color.CYAN)
|
||||
});
|
||||
|
||||
try (TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
|
||||
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf - invalid archives")
|
||||
class InvalidArchiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty ZIP (no entries) is rejected")
|
||||
void emptyArchive() throws Exception {
|
||||
byte[] archive = buildCbz(new String[] {}, new byte[][] {});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ZIP with no image entries is rejected as 'no images'")
|
||||
void noImageEntries() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"a.txt", "b.json"},
|
||||
new byte[][] {
|
||||
"x".getBytes(StandardCharsets.UTF_8),
|
||||
"{}".getBytes(StandardCharsets.UTF_8)
|
||||
});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-ZIP bytes are rejected as an invalid CBZ format")
|
||||
void corruptArchive() {
|
||||
byte[] notAZip = "this is definitely not a zip file".getBytes(StandardCharsets.UTF_8);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(notAZip), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a CBZ whose only image is corrupt produces no pages and is rejected")
|
||||
void corruptImageProducesNoPages() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"broken.png"},
|
||||
new byte[][] {"not a real png".getBytes(StandardCharsets.UTF_8)});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("@TempDir cleanup")
|
||||
class CleanupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the returned TempFile lives under the configured temp dir and closes cleanly")
|
||||
void tempFileCleanup() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(new String[] {"p.png"}, new byte[][] {pngBytes(Color.MAGENTA)});
|
||||
|
||||
TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false);
|
||||
Path path = resultPdf.getPath();
|
||||
assertThat(Files.exists(path)).isTrue();
|
||||
resultPdf.close();
|
||||
assertThat(Files.exists(path)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.util.EmlParser.EmailAttachment;
|
||||
import stirling.software.common.util.EmlParser.EmailContent;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link EmlParser#extractEmailContent} driven by small real .eml strings.
|
||||
* These exercise the content-building, recipient-formatting and attachment-mapping branches plus
|
||||
* the nested {@link EmailContent}/{@link EmailAttachment} value types. No network or external tool.
|
||||
*/
|
||||
class EmlParserMoreTest {
|
||||
|
||||
private static final String TS = "Mon, 01 Jan 2024 12:00:00 +0000";
|
||||
|
||||
private static byte[] eml(String content) {
|
||||
return content.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static EmlToPdfRequest requestWithAttachments(int maxMb) {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
request.setMaxAttachmentSizeMB(maxMb);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static String simpleText(String from, String to, String subject, String body) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
TS,
|
||||
body);
|
||||
}
|
||||
|
||||
private static String multipartWithAttachment(
|
||||
String boundary, String body, String filename, String attachmentContent) {
|
||||
String encoded =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: a@example.com\nTo: b@example.com\nCc: c@example.com\n"
|
||||
+ "Subject: Multipart\nDate: %s\n"
|
||||
+ "Content-Type: multipart/mixed; boundary=\"%s\"\n\n"
|
||||
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n%s\n\n"
|
||||
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Disposition: attachment; filename=\"%s\"\n"
|
||||
+ "Content-Transfer-Encoding: base64\n\n%s\n\n--%s--",
|
||||
TS,
|
||||
boundary,
|
||||
boundary,
|
||||
body,
|
||||
boundary,
|
||||
filename,
|
||||
encoded,
|
||||
boundary);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - headers and bodies")
|
||||
class HeaderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("subject, from, to and plain-text body are extracted")
|
||||
void plainTextEmail() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
simpleText(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Hello Subject",
|
||||
"Body line one")),
|
||||
null,
|
||||
null);
|
||||
|
||||
assertThat(content.getSubject()).isEqualTo("Hello Subject");
|
||||
assertThat(content.getFrom()).contains("sender@example.com");
|
||||
assertThat(content.getTo()).contains("recipient@example.com");
|
||||
assertThat(content.getTextBody()).contains("Body line one");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the sent date is parsed into a UTC ZonedDateTime")
|
||||
void parsesDate() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "Dated", "hi")), null, null);
|
||||
ZonedDateTime date = content.getDate();
|
||||
assertThat(date).isNotNull();
|
||||
assertThat(date.getYear()).isEqualTo(2024);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an HTML body is captured as the html body")
|
||||
void htmlBodyCaptured() throws Exception {
|
||||
String html =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"From: a@x.com\nTo: b@x.com\nSubject: HtmlMail\nDate: %s\n"
|
||||
+ "Content-Type: text/html; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "<html><body><p>Rich</p></body></html>",
|
||||
TS);
|
||||
|
||||
EmailContent content = EmlParser.extractEmailContent(eml(html), null, null);
|
||||
assertThat(content.getHtmlBody()).contains("Rich");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - attachments")
|
||||
class AttachmentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("attachment metadata is mapped and CC recipients are formatted")
|
||||
void attachmentMappedAndCc() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b1",
|
||||
"see attached",
|
||||
"notes.txt",
|
||||
"attachment payload")),
|
||||
requestWithAttachments(10),
|
||||
null);
|
||||
|
||||
assertThat(content.getCc()).contains("c@example.com");
|
||||
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
|
||||
EmailAttachment att = content.getAttachments().get(0);
|
||||
assertThat(att.getFilename()).isEqualTo("notes.txt");
|
||||
assertThat(att.getData()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("when attachments are not requested the data bytes are omitted")
|
||||
void attachmentDataOmittedWhenNotRequested() throws Exception {
|
||||
EmlToPdfRequest noAttach = new EmlToPdfRequest();
|
||||
noAttach.setIncludeAttachments(false);
|
||||
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b2", "body", "doc.txt", "some content")),
|
||||
noAttach,
|
||||
null);
|
||||
|
||||
// Metadata still present, but the raw bytes are not attached.
|
||||
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(content.getAttachments().get(0).getData()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an attachment over the size limit has its data skipped")
|
||||
void attachmentOverSizeLimitSkipped() throws Exception {
|
||||
// 0 MB limit means any non-empty attachment exceeds it.
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b3",
|
||||
"body",
|
||||
"big.txt",
|
||||
"this content exceeds the zero-byte limit")),
|
||||
requestWithAttachments(0),
|
||||
null);
|
||||
|
||||
assertThat(content.getAttachments().get(0).getData()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - failure paths")
|
||||
class FailureTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("OLE2 magic bytes that are not a real MSG file raise an IOException")
|
||||
void fakeMsgFile() {
|
||||
// OLE2/MSG magic prefix followed by garbage -> outlookMsgToEmail fails.
|
||||
byte[] fakeMsg = {
|
||||
(byte) 0xD0,
|
||||
(byte) 0xCF,
|
||||
(byte) 0x11,
|
||||
(byte) 0xE0,
|
||||
(byte) 0xA1,
|
||||
(byte) 0xB1,
|
||||
(byte) 0x1A,
|
||||
(byte) 0xE1,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
0x05,
|
||||
0x06,
|
||||
0x07
|
||||
};
|
||||
assertThatThrownBy(() -> EmlParser.extractEmailContent(fakeMsg, null, null))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EmailContent value type")
|
||||
class EmailContentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setHtmlBody and setTextBody strip carriage returns")
|
||||
void stripsCarriageReturns() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
|
||||
content.setHtmlBody("line1\r\nline2");
|
||||
content.setTextBody("a\r\nb");
|
||||
assertThat(content.getHtmlBody()).doesNotContain("\r");
|
||||
assertThat(content.getTextBody()).doesNotContain("\r");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null bodies are preserved as null")
|
||||
void nullBodiesPreserved() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
|
||||
content.setHtmlBody(null);
|
||||
assertThat(content.getHtmlBody()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EmailAttachment value type")
|
||||
class EmailAttachmentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setData updates the size in bytes")
|
||||
void setDataUpdatesSize() {
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setData(new byte[] {1, 2, 3, 4, 5});
|
||||
assertThat(att.getSizeBytes()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setData with null leaves size unchanged")
|
||||
void setDataNull() {
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setData(null);
|
||||
assertThat(att.getSizeBytes()).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.util.EmlParser.EmailAttachment;
|
||||
import stirling.software.common.util.EmlParser.EmailContent;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for the HTML-generation and helper methods of {@link EmlProcessingUtils}. All
|
||||
* inputs are built in-memory; no sanitizer, network or external tool is used.
|
||||
*/
|
||||
class EmlProcessingUtilsMoreTest {
|
||||
|
||||
private static EmailContent content(String subject, String from, String to) {
|
||||
EmailContent content = new EmailContent();
|
||||
content.setSubject(subject);
|
||||
content.setFrom(from);
|
||||
content.setTo(to);
|
||||
return content;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateEnhancedEmailHtml")
|
||||
class GenerateHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("produces a full HTML document with the subject and core headers")
|
||||
void basicDocument() {
|
||||
EmailContent content = content("My Subject", "from@x.com", "to@x.com");
|
||||
content.setTextBody("plain body text");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("<!DOCTYPE html>")
|
||||
.contains("My Subject")
|
||||
.contains("from@x.com")
|
||||
.contains("to@x.com")
|
||||
.contains("plain body text")
|
||||
.contains("</body></html>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renders CC, BCC and a formatted date when present")
|
||||
void ccBccAndDate() {
|
||||
EmailContent content = content("Sub", "from@x.com", "to@x.com");
|
||||
content.setCc("cc@x.com");
|
||||
content.setBcc("bcc@x.com");
|
||||
content.setDate(ZonedDateTime.of(2024, 5, 6, 7, 8, 0, 0, ZoneOffset.UTC));
|
||||
content.setTextBody("hi");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("CC:")
|
||||
.contains("cc@x.com")
|
||||
.contains("BCC:")
|
||||
.contains("bcc@x.com")
|
||||
.contains("Date:");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("prefers the HTML body over the text body when both are present")
|
||||
void prefersHtmlBody() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setHtmlBody("<p>html version</p>");
|
||||
content.setTextBody("text version");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("html version");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to a no-content placeholder when both bodies are empty")
|
||||
void noContentPlaceholder() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("No content available");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renders an attachments section and respects includeAttachments wording")
|
||||
void attachmentsSection() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setTextBody("body");
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setFilename("file.pdf");
|
||||
att.setContentType("application/pdf");
|
||||
att.setData(new byte[] {1, 2, 3});
|
||||
List<EmailAttachment> list = new ArrayList<>();
|
||||
list.add(att);
|
||||
content.setAttachments(list);
|
||||
content.setAttachmentCount(1);
|
||||
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, request, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("Attachments (1)")
|
||||
.contains("file.pdf")
|
||||
.contains("embedded in the file");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shows the not-included note when attachments are not requested")
|
||||
void attachmentsNotIncludedNote() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setTextBody("body");
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setFilename("a.txt");
|
||||
List<EmailAttachment> list = new ArrayList<>();
|
||||
list.add(att);
|
||||
content.setAttachments(list);
|
||||
content.setAttachmentCount(1);
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("files not included in PDF");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createHtmlRequest")
|
||||
class CreateHtmlRequestTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("copies the file input and applies the default zoom")
|
||||
void copiesFileInputAndZoom() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
|
||||
assertThat(htmlRequest).isNotNull();
|
||||
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tolerates a null request and still sets the zoom")
|
||||
void nullRequest() {
|
||||
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(null);
|
||||
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("simplifyHtmlContent")
|
||||
class SimplifyHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("strips script and style tags")
|
||||
void stripsScriptAndStyle() {
|
||||
String html =
|
||||
"<html><head><style>.a{}</style></head>"
|
||||
+ "<body><script>alert(1)</script><p>keep</p></body></html>";
|
||||
String result = EmlProcessingUtils.simplifyHtmlContent(html);
|
||||
assertThat(result).doesNotContain("<script").doesNotContain("<style").contains("keep");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("decodeMimeHeader - quoted-printable charset handling")
|
||||
class DecodeMimeHeaderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodes a quoted-printable hex sequence into the right characters")
|
||||
void decodesQpHex() {
|
||||
// =E9 in ISO-8859-1 is 'é'.
|
||||
String result = EmlProcessingUtils.decodeMimeHeader("=?ISO-8859-1?Q?caf=E9?=");
|
||||
assertThat(result).isEqualTo("café");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown charset falls back without throwing")
|
||||
void unknownCharsetFallback() {
|
||||
String result = EmlProcessingUtils.decodeMimeHeader("=?MADE-UP-CHARSET?B?SGVsbG8=?=");
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertTextToHtml - sanitizer-less escaping")
|
||||
class ConvertTextToHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("escapes HTML special characters when no sanitizer is supplied")
|
||||
void escapesSpecialChars() {
|
||||
String result = EmlProcessingUtils.convertTextToHtml("a <b> & c", null);
|
||||
assertThat(result).contains("<b>").contains("&");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectMimeType - extension table")
|
||||
class DetectMimeTypeTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("detects svg, bmp and webp from the filename")
|
||||
void detectsExtraTypes() {
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.svg", null)).isEqualTo("image/svg+xml");
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.bmp", null)).isEqualTo("image/bmp");
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.webp", null)).isEqualTo("image/webp");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.ErrorCode;
|
||||
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
|
||||
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
|
||||
|
||||
/**
|
||||
* Remaining-gap tests for {@link ExceptionUtils} not already covered by ExceptionUtilsTest /
|
||||
* ExceptionUtilsGapTest: the two-argument Ghostscript factory, the cause-bearing exception
|
||||
* constructors, and the EPS-multipage Ghostscript diagnostic branch.
|
||||
*/
|
||||
class ExceptionUtilsExtraTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("createGhostscriptCompressionException(processOutput, cause)")
|
||||
class TwoArgGhostscriptTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("both output and cause provided yields a coded exception with the cause")
|
||||
void outputAndCause() {
|
||||
Exception cause = new RuntimeException("boom");
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException(
|
||||
"Some informational chatter", cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("EPS-multipage marker is recognized as a page-drawing error")
|
||||
void epsMultipageMarker() {
|
||||
String output = "Page 1\nEPS files may not contain multiple pages";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
assertNotNull(ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-string overload with informational output uses compression code")
|
||||
void singleStringInformational() {
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException("just chatter");
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
// The fallback informative line is appended to the base message.
|
||||
assertTrue(ex.getMessage().contains("chatter"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cause-bearing exception constructors")
|
||||
class CauseConstructorTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("CbrFormatException(message, cause, code) retains cause and code")
|
||||
void cbrWithCause() {
|
||||
Exception cause = new IllegalStateException("rar");
|
||||
CbrFormatException ex = new CbrFormatException("bad cbr", cause, "E010");
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals("E010", ex.getErrorCode());
|
||||
assertEquals("bad cbr", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CbzFormatException(message, code) leaves cause null")
|
||||
void cbzNoCause() {
|
||||
CbzFormatException ex = new CbzFormatException("bad cbz", "E015");
|
||||
assertEquals("E015", ex.getErrorCode());
|
||||
assertEquals("bad cbz", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FfmpegRequiredException(message, cause, code) retains the cause")
|
||||
void ffmpegWithCause() {
|
||||
Exception cause = new RuntimeException("no ffmpeg");
|
||||
FfmpegRequiredException ex =
|
||||
new FfmpegRequiredException("ffmpeg missing", cause, "E063");
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals("E063", ex.getErrorCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link FileMonitor}, focusing on {@code isFileReadyForProcessing} branches
|
||||
* (stale-timestamp ready path, active file-lock not-ready path) and {@code trackFiles} processing
|
||||
* of real filesystem create/modify events. Timing-sensitive readiness is forced via explicit
|
||||
* last-modified timestamps rather than sleeps to stay non-flaky.
|
||||
*/
|
||||
class FileMonitorMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private FileMonitor monitorWatching(Path watchDir, Predicate<Path> filter) throws IOException {
|
||||
RuntimePathConfig config = mock(RuntimePathConfig.class);
|
||||
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(watchDir.toString()));
|
||||
return new FileMonitor(filter, config);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isFileReadyForProcessing")
|
||||
class ReadinessTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("file with an old last-modified time and no lock is ready")
|
||||
void staleFileIsReady() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("ready.pdf");
|
||||
Files.writeString(file, "data");
|
||||
// Backdate well beyond the 5000ms freshness window so the timestamp branch marks ready.
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
|
||||
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stale file lock is acquired and released so readiness stays true")
|
||||
void staleUnlockedFileLockRoundTrips() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("roundtrip.pdf");
|
||||
Files.writeString(file, "data");
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
|
||||
|
||||
// First call acquires+releases a lock and returns ready; a second call still works,
|
||||
// proving the lock was released (no lingering handle).
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recently modified, unlocked file is not yet ready")
|
||||
void freshFileNotReady() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("fresh.pdf");
|
||||
Files.writeString(file, "data");
|
||||
// Just-written file is within the freshness window and not in the ready list.
|
||||
assertFalse(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("trackFiles event processing")
|
||||
class TrackFilesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-existing files are registered during construction")
|
||||
void preExistingFilesRegistered() throws IOException {
|
||||
Files.writeString(tempDir.resolve("existing.txt"), "x");
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
assertNotNull(monitor);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-existing nested directories are registered recursively")
|
||||
void nestedDirectoriesRegistered() throws IOException {
|
||||
Path nested = tempDir.resolve("sub");
|
||||
Files.createDirectories(nested);
|
||||
Files.writeString(nested.resolve("inner.txt"), "y");
|
||||
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
assertNotNull(monitor);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("create then modify then delete cycle is processed without error")
|
||||
void createModifyDeleteCycle() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
|
||||
Path file = tempDir.resolve("cycle.txt");
|
||||
Files.writeString(file, "one");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
|
||||
Files.writeString(file, "two-modified-content");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
|
||||
Files.delete(file);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a rejecting path filter still lets trackFiles run cleanly")
|
||||
void rejectingFilter() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> false);
|
||||
Files.writeString(tempDir.resolve("ignored.txt"), "z");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("subdirectory created after start is handled on the next tick")
|
||||
void subdirectoryCreatedAfterStart() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
// First tick establishes monitoring; then create a child directory + file.
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
Path newDir = tempDir.resolve("late");
|
||||
Files.createDirectories(newDir);
|
||||
Files.writeString(newDir.resolve("late.txt"), "late");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("re-registration safety net")
|
||||
class ReRegistrationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("trackFiles re-registers root dirs when nothing is currently mapped")
|
||||
void reRegistersWhenEmpty() throws IOException {
|
||||
// Root directory does not exist at construction, so nothing is registered.
|
||||
Path missing = tempDir.resolve("appears-later");
|
||||
FileMonitor monitor = monitorWatching(missing, p -> true);
|
||||
|
||||
// Now create the directory; the next tick should attempt re-registration.
|
||||
Files.createDirectories(missing);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link FileToPdf#convertHtmlToPdf}. The WeasyPrint process is fully mocked
|
||||
* via {@link MockedStatic} so the command-building, sanitization and ZIP repacking paths run
|
||||
* without launching any external tool.
|
||||
*/
|
||||
class FileToPdfMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomHtmlSanitizer sanitizer;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-htmlpdf-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
|
||||
sanitizer = mock(CustomHtmlSanitizer.class);
|
||||
// Identity sanitize so content is preserved for assertions.
|
||||
when(sanitizer.sanitize(Mockito.anyString()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
/** Build a real ZIP byte[] from name->content pairs. */
|
||||
private static byte[] buildZip(String[] names, String[] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
zos.write(contents[i].getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** mockStatic helper returning a captor of the command list passed to the executor. */
|
||||
private ProcessExecutorResult successResult() {
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
when(result.getRc()).thenReturn(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - HTML input")
|
||||
class HtmlInputTests {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
@DisplayName("builds the WeasyPrint command and returns the output bytes")
|
||||
void htmlHappyPath() throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(commandCaptor.capture());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"/usr/bin/weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8),
|
||||
"page.html",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
List<String> command = commandCaptor.getValue();
|
||||
assertThat(command.get(0)).isEqualTo("/usr/bin/weasyprint");
|
||||
assertThat(command).contains("--pdf-forms", "-e", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the HTML body is passed through the sanitizer before writing")
|
||||
void htmlIsSanitized() throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"<b>x</b>".getBytes(StandardCharsets.UTF_8),
|
||||
"doc.HTML",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
Mockito.verify(sanitizer).sanitize("<b>x</b>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - ZIP input")
|
||||
class ZipInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("html entries inside the ZIP are sanitized and repacked")
|
||||
void zipHtmlEntriesSanitized() throws Exception {
|
||||
byte[] zip =
|
||||
buildZip(
|
||||
new String[] {"index.html", "asset.css"},
|
||||
new String[] {"<p>body</p>", "p{color:red}"});
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
// Only the .html entry should be sanitized, not the .css.
|
||||
Mockito.verify(sanitizer).sanitize("<p>body</p>");
|
||||
Mockito.verify(sanitizer, Mockito.never()).sanitize("p{color:red}");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-html entries inside the ZIP are copied through unchanged")
|
||||
void zipNonHtmlCopied() throws Exception {
|
||||
byte[] zip = buildZip(new String[] {"data.txt"}, new String[] {"plain text content"});
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
// Drop the identity-stub invocation recorded during setUp.
|
||||
Mockito.clearInvocations(sanitizer);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
Mockito.verifyNoInteractions(sanitizer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - invalid input")
|
||||
class InvalidInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an unsupported extension throws before any process is started")
|
||||
void unsupportedExtension() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"data".getBytes(StandardCharsets.UTF_8),
|
||||
"document.txt",
|
||||
tempFileManager,
|
||||
sanitizer))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sanitizeZipFilename additional branches")
|
||||
class SanitizeZipFilenameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a bare relative name is returned unchanged")
|
||||
void plainName() {
|
||||
assertThat(FileToPdf.sanitizeZipFilename("file.html")).isEqualTo("file.html");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only the .. sequences are stripped, the rest of the path survives")
|
||||
void stripsTraversalKeepsTail() {
|
||||
String result = FileToPdf.sanitizeZipFilename("a/../b/c.html");
|
||||
assertThat(result).doesNotContain("..").endsWith("c.html");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("repacked ZIP integrity")
|
||||
class RepackedZipTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the temp input zip handed to weasyprint still contains the html entry")
|
||||
void repackedZipContainsEntry() throws Exception {
|
||||
byte[] zip = buildZip(new String[] {"a.html"}, new String[] {"<i>hi</i>"});
|
||||
|
||||
// Inspect the repacked zip from inside the command answer, while the temp file is
|
||||
// still on disk (it is auto-deleted once convertHtmlToPdf returns).
|
||||
List<String> entryNames = new java.util.ArrayList<>();
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doAnswer(
|
||||
invocation -> {
|
||||
List<String> command = invocation.getArgument(0);
|
||||
Path inputZip = Path.of(command.get(command.size() - 2));
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(
|
||||
java.nio.file.Files.newInputStream(inputZip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entryNames.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return successResult();
|
||||
})
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(entryNames).anyMatch(name -> name.endsWith("a.html"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo;
|
||||
|
||||
class GeneralUtilsLocalIpTest {
|
||||
|
||||
private static NetworkInterfaceInfo iface(
|
||||
String name, String displayName, int index, boolean virtual, String... ips) {
|
||||
return new NetworkInterfaceInfo(
|
||||
name, displayName, index, true, false, false, virtual, true, List.of(ips));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersPhysicalWifiOverVmwareNatAdapter() {
|
||||
NetworkInterfaceInfo vmware =
|
||||
iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1");
|
||||
NetworkInterfaceInfo wifi =
|
||||
iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50");
|
||||
|
||||
assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesHyperVVethernetAdapter() {
|
||||
NetworkInterfaceInfo hyperv =
|
||||
iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1");
|
||||
NetworkInterfaceInfo ethernet =
|
||||
iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20");
|
||||
|
||||
assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesWslAndDockerBridges() {
|
||||
NetworkInterfaceInfo wsl =
|
||||
iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1");
|
||||
NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1");
|
||||
NetworkInterfaceInfo lan =
|
||||
iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5");
|
||||
|
||||
assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefers192Over10WhenBothPhysical() {
|
||||
NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3");
|
||||
NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10");
|
||||
|
||||
assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void breaksTiesByLowestInterfaceIndex() {
|
||||
NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2");
|
||||
NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3");
|
||||
|
||||
assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenOnlyVirtualOrDownInterfaces() {
|
||||
NetworkInterfaceInfo vbox =
|
||||
iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1");
|
||||
NetworkInterfaceInfo flaggedVirtual =
|
||||
new NetworkInterfaceInfo(
|
||||
"eth9",
|
||||
"Ethernet",
|
||||
9,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
List.of("192.168.1.9"));
|
||||
NetworkInterfaceInfo down =
|
||||
new NetworkInterfaceInfo(
|
||||
"eth0",
|
||||
"Ethernet",
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
List.of("192.168.1.2"));
|
||||
|
||||
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() {
|
||||
assertTrue(
|
||||
GeneralUtils.isLikelyVirtualInterface(
|
||||
"vEthernet", "Hyper-V Virtual Ethernet Adapter"));
|
||||
assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0"));
|
||||
assertTrue(
|
||||
GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter"));
|
||||
assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel"));
|
||||
|
||||
assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201"));
|
||||
assertFalse(
|
||||
GeneralUtils.isLikelyVirtualInterface(
|
||||
"eth0", "Realtek PCIe GbE Family Controller"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
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,4 +97,51 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:2.22.0'
|
||||
implementation "commons-io:commons-io:$commonsIoVersion"
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
@@ -81,25 +81,24 @@ 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:1.9.1'
|
||||
implementation "org.mozilla:rhino:${rhinoVersion}"
|
||||
|
||||
// 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 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation 'com.google.code.gson:gson:2.13.2'
|
||||
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
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:1.19'
|
||||
implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}"
|
||||
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
|
||||
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
|
||||
runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}"
|
||||
|
||||
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
||||
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
||||
@@ -332,8 +331,8 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
|
||||
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
|
||||
// api-landing.html is a real backend source file, not a generated artifact.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html'])
|
||||
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
|
||||
// Nested prerendered route pages (e.g. settings/people.html)
|
||||
delete new File(resourcesStaticDir, 'settings')
|
||||
}
|
||||
|
||||
+96
-15
@@ -24,12 +24,14 @@ import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Provider;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
|
||||
import org.bouncycastle.cert.jcajce.JcaCertStore;
|
||||
@@ -50,6 +52,13 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
@Getter private Certificate[] certificateChain;
|
||||
@Setter private String tsaUrl;
|
||||
|
||||
/**
|
||||
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
|
||||
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
|
||||
* the token. Left {@code null} for software keystores, which use the default provider.
|
||||
*/
|
||||
@Setter private Provider signingProvider;
|
||||
|
||||
/**
|
||||
* Specifies whether the external signing scenario should be used. If set to {@code true},
|
||||
* external signing will be performed and {@link SignatureInterface} will be used for signing.
|
||||
@@ -80,25 +89,48 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
// grabs the first alias from the keystore and get the private key. An
|
||||
// alternative method or constructor could be used for setting a specific
|
||||
// alias that should be used.
|
||||
this(keystore, pin, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
|
||||
* hardware token / the Windows store can hold several certificates, so the caller picks one;
|
||||
* when {@code requestedAlias} is null the first usable entry is used (software keystore
|
||||
* behaviour).
|
||||
*
|
||||
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
|
||||
* @param pin the keystore / token PIN, may be null for the Windows store
|
||||
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
|
||||
*/
|
||||
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
|
||||
throws KeyStoreException,
|
||||
UnrecoverableKeyException,
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
if (requestedAlias != null
|
||||
&& !requestedAlias.isBlank()
|
||||
&& keystore.containsAlias(requestedAlias)) {
|
||||
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
|
||||
certificateChain = resolveChain(keystore, requestedAlias);
|
||||
if (certificateChain == null) {
|
||||
throw new IOException("Could not find certificate for alias " + requestedAlias);
|
||||
}
|
||||
checkValidity(certificateChain[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// grabs the first alias from the keystore and gets the private key.
|
||||
Enumeration<String> aliases = keystore.aliases();
|
||||
String alias;
|
||||
Certificate cert = null;
|
||||
while (cert == null && aliases.hasMoreElements()) {
|
||||
alias = aliases.nextElement();
|
||||
String alias = aliases.nextElement();
|
||||
privateKey = (PrivateKey) keystore.getKey(alias, pin);
|
||||
Certificate[] certChain = keystore.getCertificateChain(alias);
|
||||
Certificate[] certChain = resolveChain(keystore, alias);
|
||||
if (certChain != null) {
|
||||
certificateChain = certChain;
|
||||
cert = certChain[0];
|
||||
if (cert instanceof X509Certificate) {
|
||||
// avoid expired certificate
|
||||
((X509Certificate) cert).checkValidity();
|
||||
|
||||
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
|
||||
}
|
||||
checkValidity(cert);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +139,27 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
|
||||
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
|
||||
*/
|
||||
private static Certificate[] resolveChain(KeyStore keystore, String alias)
|
||||
throws KeyStoreException {
|
||||
Certificate[] chain = keystore.getCertificateChain(alias);
|
||||
if (chain != null && chain.length > 0) {
|
||||
return chain;
|
||||
}
|
||||
Certificate single = keystore.getCertificate(alias);
|
||||
return single != null ? new Certificate[] {single} : null;
|
||||
}
|
||||
|
||||
private static void checkValidity(Certificate cert) throws CertificateException {
|
||||
if (cert instanceof X509Certificate x509Cert) {
|
||||
// avoid expired certificate
|
||||
x509Cert.checkValidity();
|
||||
}
|
||||
}
|
||||
|
||||
public final void setPrivateKey(PrivateKey privateKey) {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
@@ -136,12 +189,18 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
try {
|
||||
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
|
||||
X509Certificate cert = (X509Certificate) certificateChain[0];
|
||||
ContentSigner sha1Signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
|
||||
JcaContentSignerBuilder signerBuilder =
|
||||
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
|
||||
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
|
||||
// operation runs on the token; software keys use the default provider.
|
||||
if (signingProvider != null) {
|
||||
signerBuilder.setProvider(signingProvider);
|
||||
}
|
||||
ContentSigner signer = signerBuilder.build(privateKey);
|
||||
gen.addSignerInfoGenerator(
|
||||
new JcaSignerInfoGeneratorBuilder(
|
||||
new JcaDigestCalculatorProviderBuilder().build())
|
||||
.build(sha1Signer, cert));
|
||||
.build(signer, cert));
|
||||
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
|
||||
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
|
||||
CMSSignedData signedData = gen.generate(msg, false);
|
||||
@@ -157,4 +216,26 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
|
||||
* default; EC / EdDSA tokens are common, so they are handled too.
|
||||
*/
|
||||
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
|
||||
String alg = key.getAlgorithm();
|
||||
if (alg == null || alg.isBlank()) {
|
||||
alg = cert.getPublicKey().getAlgorithm();
|
||||
}
|
||||
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
|
||||
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
|
||||
return "Ed25519";
|
||||
}
|
||||
if (alg.contains("EC")) { // EC, ECDSA
|
||||
return "SHA256withECDSA";
|
||||
}
|
||||
if (alg.contains("DSA")) {
|
||||
return "SHA256withDSA";
|
||||
}
|
||||
return "SHA256withRSA";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.PdfMetricsService;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PdfMetricsInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final PdfMetricsService pdfMetricsService;
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex) {
|
||||
try {
|
||||
if (!pdfMetricsService.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) {
|
||||
return;
|
||||
}
|
||||
String path = request.getServletPath();
|
||||
if (path == null || path.isBlank()) {
|
||||
path = request.getRequestURI();
|
||||
}
|
||||
if (path == null || !path.contains("/api/v1/")) {
|
||||
return;
|
||||
}
|
||||
if (!(request instanceof MultipartHttpServletRequest multipart)) {
|
||||
return;
|
||||
}
|
||||
if (isFromEditor(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> bucket : multipart.getMultiFileMap().values()) {
|
||||
fileCount += bucket.size();
|
||||
}
|
||||
if (fileCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pdfMetricsService.recordOperation(fileCount);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record PDF metrics", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT.
|
||||
// JWTs start "eyJ" and have two dots; API keys do not, so they still count as API.
|
||||
private boolean isFromEditor(HttpServletRequest request) {
|
||||
String browserId = request.getHeader("X-Browser-Id");
|
||||
if (browserId != null && !browserId.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return false;
|
||||
}
|
||||
String token = auth.substring(7).trim();
|
||||
return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final EndpointInterceptor endpointInterceptor;
|
||||
private final PdfMetricsInterceptor pdfMetricsInterceptor;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
|
||||
@@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
registry.addInterceptor(pdfMetricsInterceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -83,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/icons/**",
|
||||
"/modern-logo/**",
|
||||
"/classic-logo/**",
|
||||
"/robots.txt",
|
||||
"/3rdPartyLicenses.json",
|
||||
"/pdfjs/**",
|
||||
"/pdfjs-legacy/**",
|
||||
|
||||
+14
-2
@@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
@@ -261,10 +264,19 @@ public class RearrangePagesPDFController {
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
|
||||
// Snapshot the desired pages before mutating the source document's page tree.
|
||||
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
|
||||
// so each slot is a distinct node, not one PDPage under multiple /Kids.
|
||||
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
|
||||
Set<Integer> seenIndices = new HashSet<>();
|
||||
for (Integer idx : newPageOrder) {
|
||||
newPages.add(document.getPage(idx));
|
||||
PDPage page = document.getPage(idx);
|
||||
if (!seenIndices.add(idx)) {
|
||||
// Duplicate index: distinct page node sharing content/resources.
|
||||
COSDictionary clonedDict = new COSDictionary();
|
||||
clonedDict.addAll(page.getCOSObject());
|
||||
page = new PDPage(clonedDict);
|
||||
}
|
||||
newPages.add(page);
|
||||
}
|
||||
|
||||
// Rearrange in-place on the source document rather than copying pages into a
|
||||
|
||||
+21
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -37,6 +38,7 @@ 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;
|
||||
|
||||
@@ -48,6 +50,8 @@ 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) {
|
||||
@@ -56,6 +60,7 @@ public class ConfigController {
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.userService = userService;
|
||||
this.showAdmin = showAdmin;
|
||||
this.licenseService = licenseService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
}
|
||||
@@ -315,6 +320,10 @@ 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());
|
||||
@@ -341,6 +350,18 @@ public class ConfigController {
|
||||
"serverCertificateEnabled",
|
||||
serverCertificateService != null && serverCertificateService.isEnabled());
|
||||
|
||||
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
|
||||
// desktop bundle, where the backend runs locally in the user's session. The Tauri
|
||||
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
|
||||
// the bare-jar desktop launcher signals it via a Client-* machineType.
|
||||
boolean hardwareSigningAvailable =
|
||||
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
|
||||
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
|
||||
String mt = applicationContext.getBean("machineType", String.class);
|
||||
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
|
||||
}
|
||||
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
/**
|
||||
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
|
||||
* it requires authentication when login is enabled (the modal is shown after login, never on the
|
||||
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
|
||||
* from disk, so admin edits take effect on the next login without a restart.
|
||||
*/
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
public class LoginDisclaimerController {
|
||||
|
||||
private final LoginAgreementService loginAgreementService;
|
||||
|
||||
@GetMapping("/login-disclaimer")
|
||||
@Operation(
|
||||
summary = "Get the login agreement/disclaimer",
|
||||
description =
|
||||
"Returns whether the login agreement is enabled and, if so, the markdown to"
|
||||
+ " display for the requested language.")
|
||||
public LoginDisclaimerResponse getLoginDisclaimer(
|
||||
@RequestParam(name = "lang", required = false) String lang) {
|
||||
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
|
||||
if (!loginAgreementService.isEnabled()) {
|
||||
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
|
||||
}
|
||||
String content = loginAgreementService.resolveContent(lang);
|
||||
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
|
||||
// report disabled so clients don't try to render an empty agreement.
|
||||
boolean hasContent = content != null && !content.isBlank();
|
||||
return new LoginDisclaimerResponse(
|
||||
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
|
||||
}
|
||||
|
||||
public record LoginDisclaimerResponse(
|
||||
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
|
||||
}
|
||||
+63
-4
@@ -70,10 +70,13 @@ import io.micrometer.common.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -109,14 +112,17 @@ public class CertSignController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final HardwareKeyStoreService hardwareKeyStoreService;
|
||||
|
||||
public CertSignController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
|
||||
TempFileManager tempFileManager) {
|
||||
TempFileManager tempFileManager,
|
||||
HardwareKeyStoreService hardwareKeyStoreService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.hardwareKeyStoreService = hardwareKeyStoreService;
|
||||
}
|
||||
|
||||
public static void sign(
|
||||
@@ -170,7 +176,8 @@ public class CertSignController {
|
||||
"This endpoint accepts a PDF file, a digital certificate and related"
|
||||
+ " information to sign the PDF. It then returns the digitally signed PDF"
|
||||
+ " file. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
|
||||
public ResponseEntity<Resource> signPDFWithCert(
|
||||
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
|
||||
throws Exception {
|
||||
MultipartFile pdf = request.getFileInput();
|
||||
String certType = request.getCertType();
|
||||
@@ -196,6 +203,8 @@ public class CertSignController {
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
Provider signingProvider = null;
|
||||
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@@ -245,6 +254,31 @@ public class CertSignController {
|
||||
ks = serverCertificateService.getServerKeyStore();
|
||||
keystorePassword = serverCertificateService.getServerCertificatePassword();
|
||||
break;
|
||||
case "WINDOWS_STORE":
|
||||
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
|
||||
ks = hardwareKeyStoreService.loadWindowsKeyStore();
|
||||
signingProvider = hardwareKeyStoreService.windowsProvider();
|
||||
// PIN is prompted by the Windows CSP / token middleware, not passed here.
|
||||
keystorePassword = password;
|
||||
break;
|
||||
case "PKCS11":
|
||||
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
|
||||
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
|
||||
try {
|
||||
pkcs11Session =
|
||||
hardwareKeyStoreService.openPkcs11(
|
||||
request.getPkcs11LibraryPath(),
|
||||
request.getPkcs11Slot(),
|
||||
pkcs11Pin);
|
||||
} finally {
|
||||
if (pkcs11Pin != null) {
|
||||
java.util.Arrays.fill(pkcs11Pin, '\0');
|
||||
}
|
||||
}
|
||||
ks = pkcs11Session.keyStore();
|
||||
signingProvider = pkcs11Session.provider();
|
||||
keystorePassword = password;
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@@ -252,7 +286,9 @@ public class CertSignController {
|
||||
"certificate type: " + certType);
|
||||
}
|
||||
|
||||
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
|
||||
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
|
||||
CreateSignature createSignature =
|
||||
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
|
||||
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
|
||||
sign(
|
||||
@@ -269,6 +305,14 @@ public class CertSignController {
|
||||
} catch (IOException e) {
|
||||
signedOut.close();
|
||||
throw e;
|
||||
} finally {
|
||||
// Clear the PIN copy and log out the token session once signing is done.
|
||||
if (pin != null) {
|
||||
java.util.Arrays.fill(pin, '\0');
|
||||
}
|
||||
if (pkcs11Session != null) {
|
||||
pkcs11Session.close();
|
||||
}
|
||||
}
|
||||
// Return the signed PDF
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
@@ -324,7 +368,22 @@ public class CertSignController {
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
super(keystore, pin);
|
||||
this(keystore, pin, null, null);
|
||||
}
|
||||
|
||||
public CreateSignature(
|
||||
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
|
||||
throws KeyStoreException,
|
||||
UnrecoverableKeyException,
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
super(keystore, pin, alias);
|
||||
setSigningProvider(signingProvider);
|
||||
loadLogo();
|
||||
}
|
||||
|
||||
private void loadLogo() throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
logoFile = Files.createTempFile("signature", ".png").toFile();
|
||||
|
||||
+29
@@ -305,6 +305,23 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
|
||||
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
|
||||
* entry).
|
||||
*/
|
||||
private static final java.util.Set<String> STANDARD_INFO_KEYS =
|
||||
java.util.Set.of(
|
||||
"Title",
|
||||
"Author",
|
||||
"Subject",
|
||||
"Keywords",
|
||||
"Producer",
|
||||
"Creator",
|
||||
"CreationDate",
|
||||
"ModDate",
|
||||
"Trapped");
|
||||
|
||||
private static ObjectNode extractMetadata(PDDocument document) {
|
||||
ObjectNode metadata = objectMapper.createObjectNode();
|
||||
|
||||
@@ -335,6 +352,18 @@ public class GetInfoOnPDF {
|
||||
if (modificationDate != null) {
|
||||
metadata.put("ModificationDate", modificationDate);
|
||||
}
|
||||
|
||||
// Surface custom Info-dictionary entries (anything beyond the
|
||||
// standard fields above) — e.g. StirlingPDFClassification
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
if (STANDARD_INFO_KEYS.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = info.getCustomMetadataValue(key);
|
||||
if (value != null && !value.isBlank()) {
|
||||
metadata.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting metadata: {}", e.getMessage());
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
|
||||
import stirling.software.SPDF.model.api.security.Pkcs11CertificatesRequest;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
|
||||
/**
|
||||
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
|
||||
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
|
||||
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
|
||||
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security/cert-sign/hardware")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
public class HardwareSigningController {
|
||||
|
||||
private final HardwareKeyStoreService hardwareKeyStoreService;
|
||||
|
||||
@GetMapping("/capabilities")
|
||||
@Operation(
|
||||
summary = "Hardware signing capabilities",
|
||||
description =
|
||||
"Reports whether hardware-backed signing is available on this device and which"
|
||||
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
|
||||
+ " not running as the desktop app.")
|
||||
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
|
||||
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
|
||||
}
|
||||
|
||||
@GetMapping("/windows-certificates")
|
||||
@Operation(
|
||||
summary = "List Windows certificate store signing certificates",
|
||||
description =
|
||||
"Enumerates certificates with a usable private key from the current user's"
|
||||
+ " Windows certificate store. Desktop-only, loopback-only.")
|
||||
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
|
||||
HttpServletRequest request) throws Exception {
|
||||
hardwareKeyStoreService.assertLocalDesktop(request);
|
||||
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
|
||||
}
|
||||
|
||||
@PostMapping("/pkcs11-certificates")
|
||||
@Operation(
|
||||
summary = "List PKCS#11 token signing certificates",
|
||||
description =
|
||||
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
|
||||
+ " certificates. The PIN is used only for this call. Desktop-only,"
|
||||
+ " loopback-only.")
|
||||
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
|
||||
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
|
||||
throws Exception {
|
||||
hardwareKeyStoreService.assertLocalDesktop(request);
|
||||
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
|
||||
try {
|
||||
return ResponseEntity.ok(
|
||||
hardwareKeyStoreService.listPkcs11Certificates(
|
||||
body.libraryPath(), body.slot(), pin));
|
||||
} finally {
|
||||
if (pin != null) {
|
||||
java.util.Arrays.fill(pin, '\0');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -102,8 +102,27 @@ public class ValidateSignatureController {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
|
||||
List<PDSignature> signatures = document.getSignatureDictionaries();
|
||||
|
||||
// Detect content appended outside every signature's ByteRange (added after signing). A
|
||||
// properly signed document has its last signature cover all the way to EOF; if the
|
||||
// furthest any signature reaches stops short of the file length, the tail is unsigned.
|
||||
// Taking the max across all signatures avoids false positives on legitimately
|
||||
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
|
||||
long fileLength = file.getSize();
|
||||
long maxCovered = 0;
|
||||
for (PDSignature sig : signatures) {
|
||||
int[] byteRange = sig.getByteRange();
|
||||
if (byteRange != null && byteRange.length == 4) {
|
||||
long end = (long) byteRange[2] + byteRange[3];
|
||||
if (end > maxCovered) {
|
||||
maxCovered = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
|
||||
|
||||
for (PDSignature sig : signatures) {
|
||||
SignatureValidationResult result = new SignatureValidationResult();
|
||||
result.setCoversEntireDocument(documentCovered);
|
||||
|
||||
try {
|
||||
byte[] signedContent = sig.getSignedContent(file.getInputStream());
|
||||
|
||||
+41
@@ -42,6 +42,8 @@ public class ReactRoutingController {
|
||||
private boolean loggedMissingIndex = false;
|
||||
private String cachedSaasLandingHtml;
|
||||
private boolean saasLandingExists = false;
|
||||
private String cachedMobileUploadHtml;
|
||||
private boolean mobileUploadHtmlExists = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@@ -64,6 +66,12 @@ public class ReactRoutingController {
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
|
||||
// load the React /mobile-scanner route from the local backend. Cache the self-contained
|
||||
// static upload page to serve at that route in desktop mode instead.
|
||||
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
|
||||
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
@@ -144,6 +152,28 @@ public class ReactRoutingController {
|
||||
return new ClassPathResource("static/index.html");
|
||||
}
|
||||
|
||||
private String readStaticHtml(String filename) {
|
||||
try {
|
||||
Path external = Path.of(InstallationPathConfig.getStaticPath(), filename);
|
||||
if (Files.exists(external) && Files.isReadable(external)) {
|
||||
return Files.readString(external, StandardCharsets.UTF_8);
|
||||
}
|
||||
ClassPathResource resource = new ClassPathResource("static/" + filename);
|
||||
if (resource.exists()) {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to read static HTML {}", filename, ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isDesktopMode() {
|
||||
return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
@@ -191,6 +221,17 @@ public class ReactRoutingController {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveMobileScanner(HttpServletRequest request) {
|
||||
if (isDesktopMode() && mobileUploadHtmlExists) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(cachedMobileUploadHtml);
|
||||
}
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
|
||||
// cachedCallbackHtml is always initialized in @PostConstruct
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Serves /robots.txt dynamically so the system.googlevisibility flag actually controls
|
||||
* search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy
|
||||
* to keep the instance out of search engines (useful for embedded/internal deployments).
|
||||
*/
|
||||
@RestController
|
||||
public class RobotsController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public RobotsController(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
@ResponseBody
|
||||
public String robotsTxt() {
|
||||
boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility();
|
||||
return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n");
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
/**
|
||||
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
|
||||
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
|
||||
* certificate to sign with. Never carries private key material - signing always happens on the
|
||||
* token / OS.
|
||||
*/
|
||||
public record HardwareCertificateInfo(
|
||||
String alias,
|
||||
String source,
|
||||
String subject,
|
||||
String issuer,
|
||||
String subjectCommonName,
|
||||
String issuerCommonName,
|
||||
String serialNumber,
|
||||
String keyAlgorithm,
|
||||
String notBefore,
|
||||
String notAfter,
|
||||
boolean expired,
|
||||
boolean notYetValid) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
|
||||
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
|
||||
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
|
||||
*/
|
||||
public record HardwareSigningCapabilities(
|
||||
boolean desktop,
|
||||
String osName,
|
||||
boolean windowsStoreSupported,
|
||||
boolean pkcs11Supported,
|
||||
List<Pkcs11LibraryInfo> detectedLibraries) {
|
||||
|
||||
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
|
||||
public record Pkcs11LibraryInfo(String name, String path) {}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
/**
|
||||
* Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
|
||||
* the token; it is used only for the duration of the call and never stored.
|
||||
*/
|
||||
public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
|
||||
+27
-3
@@ -14,8 +14,10 @@ import stirling.software.common.model.api.PDFFile;
|
||||
public class SignPDFWithCertRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The type of the digital certificate",
|
||||
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
|
||||
description =
|
||||
"The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
|
||||
+ " hardware-backed and only available in the desktop app.",
|
||||
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
@@ -39,9 +41,31 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
@Schema(description = "The JKS keystore file (Java Key Store)")
|
||||
private MultipartFile jksFile;
|
||||
|
||||
@Schema(description = "The password for the keystore or the private key", format = "password")
|
||||
@Schema(
|
||||
description =
|
||||
"The password for the keystore / private key, or the token PIN for PKCS11",
|
||||
format = "password")
|
||||
private String password;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The alias of the certificate to sign with. Required for WINDOWS_STORE and"
|
||||
+ " recommended for PKCS11 tokens holding multiple certificates.")
|
||||
private String alias;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
|
||||
+ " be an allowed driver - a detected one or configured via"
|
||||
+ " STIRLING_PKCS11_LIBRARIES.")
|
||||
private String pkcs11LibraryPath;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Optional PKCS#11 slot index. When omitted the first slot with a token is"
|
||||
+ " used.")
|
||||
private Integer pkcs11Slot;
|
||||
|
||||
@Schema(
|
||||
description = "Whether to visually show the signature in the PDF file",
|
||||
defaultValue = "false",
|
||||
|
||||
+5
@@ -18,6 +18,11 @@ public class SignatureValidationResult {
|
||||
// Time validation
|
||||
private boolean notExpired;
|
||||
|
||||
// Whether the document's signatures cover all of its bytes. False when content was appended
|
||||
// outside every signature's ByteRange (i.e. added after signing), which the signature can't
|
||||
// attest to even though the signed bytes themselves remain cryptographically intact.
|
||||
private boolean coversEntireDocument = true;
|
||||
|
||||
// Revocation validation
|
||||
private boolean revocationChecked; // true if PKIX revocation was enabled
|
||||
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
|
||||
+2
-1
@@ -115,7 +115,8 @@ public class CertificateValidationService {
|
||||
log.info("Enabled AIA certificate fetching and revocation checking");
|
||||
}
|
||||
|
||||
// Trust only what we explicitly opt into:
|
||||
// Trust only what we explicitly opt into. Desktop follows the same flags as the server -
|
||||
// our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
|
||||
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
|
||||
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
|
||||
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.Provider;
|
||||
import java.security.Security;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
|
||||
import org.bouncycastle.asn1.x500.RDN;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.asn1.x500.style.IETFUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
|
||||
* provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
|
||||
* token - the JCA routes the actual signing operation onto the hardware.
|
||||
*
|
||||
* <p>These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
|
||||
* a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
|
||||
* native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
|
||||
* detected / configured driver paths.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class HardwareKeyStoreService {
|
||||
|
||||
public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
|
||||
public static final String SOURCE_PKCS11 = "PKCS11";
|
||||
|
||||
private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
|
||||
private static final String MSCAPI_PROVIDER = "SunMSCAPI";
|
||||
private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
|
||||
|
||||
/** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
|
||||
private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
|
||||
|
||||
/** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
|
||||
private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
|
||||
|
||||
private final String machineType;
|
||||
|
||||
public HardwareKeyStoreService(
|
||||
@Autowired(required = false) @Qualifier("machineType") String machineType) {
|
||||
this.machineType = machineType;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Gating
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True when running as the desktop bundle (local sidecar in the user's session). The Tauri
|
||||
* bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
|
||||
* machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
|
||||
* {@code Client-*} machineType. Accept either.
|
||||
*/
|
||||
public boolean isDesktop() {
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
return true;
|
||||
}
|
||||
return machineType != null && machineType.startsWith("Client-");
|
||||
}
|
||||
|
||||
public boolean isWindows() {
|
||||
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
|
||||
}
|
||||
|
||||
private boolean windowsStoreSupported() {
|
||||
return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
|
||||
}
|
||||
|
||||
private boolean pkcs11Supported() {
|
||||
return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
|
||||
}
|
||||
|
||||
/** Reject anything that is not the desktop bundle reached over loopback. */
|
||||
public void assertLocalDesktop(HttpServletRequest request) {
|
||||
if (!isDesktop()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.hardwareSigningDesktopOnly",
|
||||
"Hardware-backed signing is only available in the Stirling PDF desktop app");
|
||||
}
|
||||
if (request != null && !isLocalRequest(request.getRemoteAddr())) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.hardwareSigningLocalOnly",
|
||||
"Hardware-backed signing can only be used from this device");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
|
||||
* {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
|
||||
* works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
|
||||
* still rejecting other machines on the network.
|
||||
*/
|
||||
static boolean isLocalRequest(String remoteAddr) {
|
||||
if (remoteAddr == null || remoteAddr.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(remoteAddr);
|
||||
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
|
||||
return true;
|
||||
}
|
||||
return NetworkInterface.networkInterfaces()
|
||||
.anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Capabilities
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public HardwareSigningCapabilities capabilities() {
|
||||
boolean desktop = isDesktop();
|
||||
if (!desktop) {
|
||||
return new HardwareSigningCapabilities(false, "", false, false, List.of());
|
||||
}
|
||||
return new HardwareSigningCapabilities(
|
||||
true,
|
||||
System.getProperty("os.name", ""),
|
||||
windowsStoreSupported(),
|
||||
pkcs11Supported(),
|
||||
detectPkcs11Libraries());
|
||||
}
|
||||
|
||||
/**
|
||||
* Known driver install locations plus any paths configured via {@code
|
||||
* STIRLING_PKCS11_LIBRARIES}.
|
||||
*/
|
||||
public List<Pkcs11LibraryInfo> detectPkcs11Libraries() {
|
||||
Map<String, List<String>> candidates = new LinkedHashMap<>();
|
||||
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||
|
||||
if (os.contains("win")) {
|
||||
candidates.put(
|
||||
"OpenSC",
|
||||
List.of(
|
||||
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
|
||||
candidates.put(
|
||||
"YubiKey (ykcs11)",
|
||||
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
|
||||
candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
|
||||
candidates.put(
|
||||
"Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
|
||||
candidates.put(
|
||||
"SoftHSM2",
|
||||
List.of(
|
||||
"C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
|
||||
"C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
|
||||
} else if (os.contains("mac")) {
|
||||
candidates.put(
|
||||
"OpenSC",
|
||||
List.of(
|
||||
"/Library/OpenSC/lib/opensc-pkcs11.so",
|
||||
"/usr/local/lib/opensc-pkcs11.so"));
|
||||
candidates.put(
|
||||
"YubiKey (ykcs11)",
|
||||
List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
|
||||
candidates.put(
|
||||
"SoftHSM2",
|
||||
List.of(
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so",
|
||||
"/opt/homebrew/lib/softhsm/libsofthsm2.so"));
|
||||
} else {
|
||||
candidates.put(
|
||||
"OpenSC",
|
||||
List.of(
|
||||
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
|
||||
"/usr/lib/opensc-pkcs11.so",
|
||||
"/usr/lib64/opensc-pkcs11.so"));
|
||||
candidates.put(
|
||||
"YubiKey (ykcs11)",
|
||||
List.of(
|
||||
"/usr/lib/x86_64-linux-gnu/libykcs11.so",
|
||||
"/usr/local/lib/libykcs11.so"));
|
||||
candidates.put(
|
||||
"SoftHSM2",
|
||||
List.of(
|
||||
"/usr/lib/softhsm/libsofthsm2.so",
|
||||
"/usr/lib64/softhsm/libsofthsm2.so",
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so"));
|
||||
}
|
||||
|
||||
List<Pkcs11LibraryInfo> result = new ArrayList<>();
|
||||
candidates.forEach(
|
||||
(name, paths) ->
|
||||
paths.stream()
|
||||
.filter(p -> Files.exists(Path.of(p)))
|
||||
.findFirst()
|
||||
.ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
|
||||
|
||||
for (String configured : configuredLibraries()) {
|
||||
if (Files.exists(Path.of(configured))
|
||||
&& result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
|
||||
result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<String> configuredLibraries() {
|
||||
String env = System.getenv(PKCS11_LIBRARIES_ENV);
|
||||
String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
|
||||
StringBuilder combined = new StringBuilder();
|
||||
if (env != null && !env.isBlank()) {
|
||||
combined.append(env);
|
||||
}
|
||||
if (prop != null && !prop.isBlank()) {
|
||||
if (combined.length() > 0) {
|
||||
combined.append(java.io.File.pathSeparator);
|
||||
}
|
||||
combined.append(prop);
|
||||
}
|
||||
if (combined.length() == 0) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Windows certificate store
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public KeyStore loadWindowsKeyStore() throws Exception {
|
||||
if (!windowsStoreSupported()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.windowsStoreUnavailable",
|
||||
"The Windows certificate store is not available on this platform");
|
||||
}
|
||||
KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
|
||||
ks.load(null, null);
|
||||
return ks;
|
||||
}
|
||||
|
||||
public Provider windowsProvider() {
|
||||
return Security.getProvider(MSCAPI_PROVIDER);
|
||||
}
|
||||
|
||||
public List<HardwareCertificateInfo> listWindowsCertificates() throws Exception {
|
||||
return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PKCS#11 tokens
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
|
||||
* logs the session out so the PIN-authenticated session does not outlive the request. The
|
||||
* provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
|
||||
* C_Initialize. Single-user desktop model - logout is best-effort.
|
||||
*/
|
||||
public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
if (provider instanceof java.security.AuthProvider authProvider) {
|
||||
try {
|
||||
authProvider.logout();
|
||||
} catch (Exception e) {
|
||||
// Not logged in / already logged out - nothing to clear.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
|
||||
// typically allows C_Initialize only once per process, so configuring a fresh provider on every
|
||||
// call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
|
||||
// works". Reusing the provider keeps a single C_Initialize alive for the session.
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, Provider> pkcs11Providers =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
|
||||
validateLibraryAllowed(libraryPath);
|
||||
if (!pkcs11Supported()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
|
||||
}
|
||||
|
||||
String cacheKey = libraryPath + "|" + slot;
|
||||
Provider provider =
|
||||
pkcs11Providers.computeIfAbsent(
|
||||
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
|
||||
try {
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
|
||||
ks.load(null, pin);
|
||||
return new Pkcs11Session(ks, provider);
|
||||
} catch (Exception e) {
|
||||
// A wrong PIN must not be retried: a second C_Login would burn the token's retry
|
||||
// counter twice per attempt and can lock the token. Only rebuild on provider/init
|
||||
// failures (e.g. token removed/re-inserted leaving a stale provider).
|
||||
if (isAuthFailure(e)) {
|
||||
throw e;
|
||||
}
|
||||
pkcs11Providers.remove(cacheKey, provider);
|
||||
Provider fresh =
|
||||
pkcs11Providers.computeIfAbsent(
|
||||
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
|
||||
ks.load(null, pin);
|
||||
return new Pkcs11Session(ks, fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
|
||||
private static boolean isAuthFailure(Throwable t) {
|
||||
while (t != null) {
|
||||
if (t instanceof javax.security.auth.login.FailedLoginException) {
|
||||
return true;
|
||||
}
|
||||
String msg = t.getMessage();
|
||||
if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
|
||||
return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
|
||||
}
|
||||
t = t.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
|
||||
StringBuilder config = new StringBuilder();
|
||||
config.append("--name=").append(providerName(libraryPath)).append('\n');
|
||||
config.append("library=").append(libraryPath).append('\n');
|
||||
if (slot != null) {
|
||||
config.append("slot=").append(slot).append('\n');
|
||||
}
|
||||
try {
|
||||
return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
|
||||
} catch (Exception e) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11ConfigFailed",
|
||||
"Failed to initialise the PKCS#11 driver: {0}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<HardwareCertificateInfo> listPkcs11Certificates(
|
||||
String libraryPath, Integer slot, char[] pin) throws Exception {
|
||||
try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
|
||||
return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
|
||||
*/
|
||||
public void validateLibraryAllowed(String libraryPath) {
|
||||
if (libraryPath == null || libraryPath.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
|
||||
}
|
||||
Set<String> allowed =
|
||||
detectPkcs11Libraries().stream()
|
||||
.map(Pkcs11LibraryInfo::path)
|
||||
.collect(Collectors.toSet());
|
||||
boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
|
||||
if (!ok) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11LibraryNotAllowed",
|
||||
"PKCS#11 driver is not in the allowed list. Add it via the"
|
||||
+ " STIRLING_PKCS11_LIBRARIES setting: {0}",
|
||||
libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private List<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
|
||||
throws Exception {
|
||||
List<HardwareCertificateInfo> certs = new ArrayList<>();
|
||||
Enumeration<String> aliases = ks.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
String alias = aliases.nextElement();
|
||||
if (!ks.isKeyEntry(alias)) {
|
||||
continue; // only entries we can sign with
|
||||
}
|
||||
Certificate cert = ks.getCertificate(alias);
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
certs.add(toInfo(alias, x509, source));
|
||||
}
|
||||
}
|
||||
return certs;
|
||||
}
|
||||
|
||||
private static HardwareCertificateInfo toInfo(
|
||||
String alias, X509Certificate cert, String source) {
|
||||
java.util.Date now = new java.util.Date();
|
||||
return new HardwareCertificateInfo(
|
||||
alias,
|
||||
source,
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
commonName(cert.getSubjectX500Principal()),
|
||||
commonName(cert.getIssuerX500Principal()),
|
||||
cert.getSerialNumber().toString(16),
|
||||
cert.getPublicKey().getAlgorithm(),
|
||||
cert.getNotBefore().toInstant().toString(),
|
||||
cert.getNotAfter().toInstant().toString(),
|
||||
now.after(cert.getNotAfter()),
|
||||
now.before(cert.getNotBefore()));
|
||||
}
|
||||
|
||||
private static String commonName(X500Principal principal) {
|
||||
try {
|
||||
X500Name x500Name = new X500Name(principal.getName());
|
||||
RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
|
||||
if (rdns.length > 0) {
|
||||
return IETFUtils.valueToString(rdns[0].getFirst().getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not parse common name from {}", principal.getName());
|
||||
}
|
||||
return principal.getName();
|
||||
}
|
||||
|
||||
private static String providerName(String libraryPath) {
|
||||
String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
|
||||
if (base.isEmpty()) {
|
||||
base = "token";
|
||||
}
|
||||
return "StirlingHW" + base;
|
||||
}
|
||||
|
||||
private static String fileName(String path) {
|
||||
try {
|
||||
return Path.of(path).getFileName().toString();
|
||||
} catch (Exception e) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameFile(String a, String b) {
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Path pa = Path.of(a);
|
||||
Path pb = Path.of(b);
|
||||
if (Files.exists(pa) && Files.exists(pb)) {
|
||||
return Files.isSameFile(pa, pb);
|
||||
}
|
||||
return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
|
||||
} catch (Exception e) {
|
||||
return a.equalsIgnoreCase(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
|
||||
@Service
|
||||
public class PdfMetricsService {
|
||||
|
||||
private final PostHogService postHogService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private final AtomicLong operations = new AtomicLong();
|
||||
private final AtomicLong pdfs = new AtomicLong();
|
||||
private long lastOperations;
|
||||
private long lastPdfs;
|
||||
|
||||
public PdfMetricsService(
|
||||
PostHogService postHogService, ApplicationProperties applicationProperties) {
|
||||
this.postHogService = postHogService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return applicationProperties.getSystem().isPosthogEnabled();
|
||||
}
|
||||
|
||||
public void recordOperation(int pdfCount) {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
operations.incrementAndGet();
|
||||
if (pdfCount > 0) {
|
||||
pdfs.addAndGet(pdfCount);
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 7200000)
|
||||
public void flushMetrics() {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
long curOps = operations.get();
|
||||
long curPdfs = pdfs.get();
|
||||
long opsDelta = curOps - lastOperations;
|
||||
long pdfsDelta = curPdfs - lastPdfs;
|
||||
if (opsDelta <= 0 && pdfsDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> props = new HashMap<>();
|
||||
props.put("source", "api");
|
||||
props.put("operations", opsDelta);
|
||||
props.put("pdfs", pdfsDelta);
|
||||
postHogService.captureEvent("pdf_operation_metrics", props);
|
||||
|
||||
lastOperations = curOps;
|
||||
lastPdfs = curPdfs;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ 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,6 +19,7 @@ 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;
|
||||
@@ -63,7 +64,7 @@ public class SvgToPdf {
|
||||
}
|
||||
|
||||
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
UserAgent userAgent = createSecureUserAgent();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
@@ -94,6 +95,21 @@ 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();
|
||||
@@ -202,7 +218,7 @@ public class SvgToPdf {
|
||||
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
|
||||
}
|
||||
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
UserAgent userAgent = createSecureUserAgent();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
@@ -62,8 +62,6 @@ security:
|
||||
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
|
||||
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
|
||||
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
|
||||
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
|
||||
@@ -141,10 +139,10 @@ telegram:
|
||||
botUsername: "" # Telegram bot username (without @)
|
||||
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
|
||||
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
|
||||
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
|
||||
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
|
||||
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
|
||||
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
|
||||
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true'
|
||||
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated).
|
||||
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true'
|
||||
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated).
|
||||
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
|
||||
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
|
||||
feedback:
|
||||
@@ -165,13 +163,17 @@ legal:
|
||||
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
loginAgreement:
|
||||
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
|
||||
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
|
||||
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
|
||||
|
||||
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
|
||||
googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines
|
||||
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
|
||||
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'
|
||||
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'
|
||||
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.
|
||||
@@ -182,7 +184,7 @@ system:
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
|
||||
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
@@ -193,7 +195,7 @@ system:
|
||||
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
organizationName: Stirling PDF Inc # Organization name for generated certificates
|
||||
validity: 365 # Certificate validity in days
|
||||
regenerateOnStartup: false # Generate new certificate on each startup
|
||||
html:
|
||||
@@ -300,7 +302,7 @@ autoPipeline:
|
||||
allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"]
|
||||
|
||||
ui:
|
||||
appNameNavbar: "" # name displayed on the navigation bar
|
||||
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
|
||||
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
|
||||
|
||||
@@ -179,6 +179,13 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.code.gson:gson",
|
||||
"moduleUrl": "https://github.com/google/gson",
|
||||
"moduleVersion": "2.14.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
@@ -186,6 +193,13 @@
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
"moduleVersion": "2.48.0",
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.guava:failureaccess",
|
||||
"moduleUrl": "https://github.com/google/guava/",
|
||||
@@ -629,13 +643,6 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-io:commons-io",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
|
||||
"moduleVersion": "2.21.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-io:commons-io",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,333 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user